40 Commits

Author SHA1 Message Date
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 / 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
Release — F-Droid repo + Gitea release / ci (push) Successful in 7m43s
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 / build-and-deploy (push) Successful in 9m22s
Release — F-Droid repo + Gitea release / ci (push) Successful in 2m3s
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
210ddff8d8 release: cut v2.3.0 — Material 3 grouped-list redesign of Settings, calendars & drawer
All checks were successful
CI / ci (push) Successful in 8m2s
Release — F-Droid repo + Gitea release / ci (push) Successful in 2m1s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 8m44s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 8s
One shared Material 3 grouped-list blueprint, modelled on the ReFra gallery app
and extracted to ui/common/GroupedList.kt: CollapsingScaffold (a LargeTopAppBar
whose large title collapses into the bar on scroll) and GroupedRow
(Position-based corner grouping so a run of rows reads as one rounded card, with
press-animated corners and selected/minHeight knobs).

Settings: restructured into a category hub (About card on top, version mark at
the foot) with sliding sub-pages for Appearance, the new-event form and
Notifications. Theme, week-start and language pickers migrated from DropdownMenu
to OptionCard dialogs; token-based icon chips. New ic_gitea.xml (Simple Icons,
verbatim path) for the About "Source" button; en+de strings.

Calendar manager: same collapsing scaffold + grouped rows; shared
CalendarColorChip (neutral chip with a pastelised calendar glyph) replaces the
bright colour swatch.

Navigation drawer: branded header, grouped View switcher (active view
highlighted via secondaryContainer), filter list restyled to grouped rows with a
trailing checkbox; the whole drawer now scrolls as one.

Cards use surfaceContainerHigh for readable contrast against surface. Version
bumped to 2.3.0 / 20300. UI-only; unit tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:44:10 +02:00
e194da3766 release: cut v2.2.0 — tap-to-create + local calendar management
All checks were successful
CI / ci (push) Successful in 8m53s
Release — F-Droid repo + Gitea release / ci (push) Successful in 1m59s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 8m57s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 8s
Day/week: tap an empty slot to open the create form prefilled with that
day and the tapped hour (snapped to the hour, 1 h long). Threaded a start
time through CalendarHost → EventEditScreen → openNew; the FAB keeps its
default.

Local calendars: a full-screen editor from Settings → Calendars to
create/rename/recolor/delete device-only calendars (ACCOUNT_TYPE_LOCAL,
sync-adapter insert) with name, pastel-previewed colour, and a description
(stored in CAL_SYNC1). Synced calendars are listed read-only grouped by
account, each with a "manage in source app" deep-link resolved from the
account's own authenticator (DAVx5/ICSx5/…), plus an add-account shortcut;
a <queries> block makes the source apps launchable. Extracted a shared
InlineTextField into ui.common so the event form and calendar editor share
one borderless input style.

Tests: repository delegation + write-failure, mapper isLocal/description,
fake data source extended. Version bumped to 2.2.0 / 20200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:49:14 +02:00
15fb76005c release: cut v2.1.0 — month event grid, drawer view tabs, text-cursor fix
All checks were successful
CI / ci (push) Successful in 8m30s
Release — F-Droid repo + Gitea release / ci (push) Successful in 2m3s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 8m57s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 8s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:30:20 +02:00
c27a645c19 feat(month): show real events with continuous multi-day bars
Replace the per-day dot summary with an event-rich grid. The ViewModel now
splits the grid into week rows and, per row, resolves all-day/multi-day
events into spanning bars (reusing the week view's layoutAllDay lane math)
and single-day timed events into per-day pills.

The grid renders as an overlay: each day gets a rounded surfaceContainer
background (matching the week/day views), spanning bars draw on top so a
multi-day event is one connected bar bridging the cells it covers, and
single-day pills fill the lane slots no bar occupies on that specific day
(top-most first) so a bar-free day isn't pushed down. Up to three rows
show per day, then a "+N" dot row. Today is a filled circle on its number;
neighbour-month days are dimmed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:29:38 +02:00
21e7b1ff91 feat(drawer): add View section to switch Month/Week/Day
The slide-out panel gains a "View" section mirroring the top-bar switcher
pill: three NavigationDrawerItems (Month/Week/Day) with the current view
highlighted; tapping one selects that view and closes the drawer. The pill
stays as-is for quick cycling.

Centralise each view's label + icon as labelRes/icon extensions on
CalendarView so the pill and the drawer share one mapping. The drawer's
"Today" jump is dropped — the top-bar Today action and error-state retry
still cover it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:48:49 +02:00
31163da868 ci(release): P1 hardening — versioning, F-Droid changelogs, R8 mapping, docs
All checks were successful
CI / ci (push) Successful in 8m11s
P1.3 Versioning: the git tag is already the de-facto single source of truth
(every published versionCode uses MAJOR*10000+MINOR*100+PATCH; committed 13
was a stale outlier). Align the committed default to 20000 and document the
scheme in a comment + docs/RELEASING.md.

P1.4 F-Droid changelogs: a tag-only step extracts the tag's CHANGELOG section
into metadata/.../en-US/changelogs/<versionCode>.txt so clients show a
per-version "What's New". Also upload metadata/ (non-secret, never web-served)
alongside repo/ so changelog history survives across releases.

P1.5 R8 mapping: attach mapping-<version>.txt.gz to the Gitea release
(best-effort, continue-on-error) so user crash stacktraces stay
deobfuscatable. The gitea-release notes step is now an upsert (PATCH if the
release already exists) so it composes with the mapping step creating the
release first.

P1.6 docs/RELEASING.md: release ritual, versioning scheme, secrets inventory,
key custody/recovery, manual re-sign path, F-Droid repo details.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:47:50 +02:00
9a1903e6ed fix(edit): stop cursor jumping in event text fields
The event form's state pipeline ran .flowOn(io) over the whole combine,
including the _form round-trip every keystroke depends on. That async hop
handed BasicTextField a lagging value while typing, so Compose kept
correcting the cursor to the stale position.

Scope flowOn(io) to just the calendar/prefs/settings reads and collect the
form -> state -> UI path on the main dispatcher, so keystrokes round-trip
synchronously and the cursor stays put.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:16:32 +02:00
f990af1cb0 ci(release): make workflow_dispatch a key-rotation / re-sign path
All checks were successful
CI / ci (push) Successful in 4m34s
The release job assumed the ref is a version tag (Set version from git tag →
versionCode). A manual workflow_dispatch from a branch yielded versionCode 0
and Gradle aborted assembleRelease before the F-Droid steps ran.

Gate the tag-only steps (version, app keystore, assembleRelease, copy APK)
on refs/tags/*. On a manual dispatch the job now skips the APK build and just
re-signs the existing index with the configured repo key and re-uploads —
exactly what a repo-key rotation or recovery needs, no new release required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:13:43 +02:00
e5be5f1ae5 security(release): rotate compromised F-Droid repo key; keep key out of served tree
All checks were successful
CI / ci (push) Successful in 5m17s
The F-Droid repo signing key (keystore.p12) and its config.yml — including
the keystore passwords in cleartext — were publicly downloadable at
apps.dev.jeanlucmakiola.de/dev/fdroid/ because the release workflow uploaded
the entire fdroid/ working dir into the web-served path. The webserver has
since been locked down to repo/ only; this rotates the now-compromised key
and removes the root cause.

- release.yaml: restore the repo key + config from new CI secrets
  (FDROID_KEYSTORE_BASE64, FDROID_CONFIG_BASE64) instead of the box; upload
  ONLY repo/ so the key never re-enters the served tree.
- release.yaml: fail loudly when the repo key secrets are unset, replacing
  `fdroid update --create-key`, which silently minted a NEW repo key on a
  wiped server and would have broken every user's pinned fingerprint.
- README: publish the new repo fingerprint (C2C0…3425). Existing users must
  remove and re-add the repo.
- .gitignore: ignore *.p12 and the whole /fdroid/ working dir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:01:00 +02:00
54aed73726 docs: F-Droid install guide with repo URL + fingerprint; backlog daily-driver ideas
All checks were successful
CI / ci (push) Successful in 4m30s
README gains a real install path: add the self-hosted repo
(apps.dev.jeanlucmakiola.de/dev/fdroid/repo, fingerprint inline and as an
add-repo link), search, install. Verified live against the repo index.

Roadmap gains the approved daily-driver idea backlog (unscheduled): slot-tap
create, drag & drop rescheduling, agenda view, pinch-zoom, reminder
snooze/dismiss + default reminder, duplicate event, per-event color,
.ics share/receive, app shortcuts, jump-to-date — plus the consciously
rejected list (network-dependent features, NL quick entry).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:48:30 +02:00
82c3e1d605 docs: architecture tour, docs index, showcase README; ci: Gitea release per tag
All checks were successful
CI / ci (push) Successful in 4m38s
Documentation pass after the 2.0 milestone:
- docs/ARCHITECTURE.md — principles (provider as single source of truth,
  observer-driven UI, JVM-first tests, no network), layer + reminder
  mermaid diagrams, navigation (overlay/held-key, no nav lib), and the
  provider lessons (recurring-write invariants, conflict snapshots)
- docs/README.md — map of what documentation lives where, incl. the
  convention that superpowers/ plans are historical artifacts while
  .planning/ stays current
- README.md — showcase layout (centered header, badges, screenshot
  gallery from the fastlane assets, grouped features, install/build/
  architecture/roadmap sections); renders on Gitea
- .planning/{PROJECT,REQUIREMENTS,STATE}.md unstaled: read-only-V1 talk
  removed, V1/V2 checklists marked shipped, state points at v3 + the
  Locations & People go/no-go

release.yaml gains a gitea-release job: on every tag push it extracts the
tag's CHANGELOG section and creates a Gitea release with it as the notes.
No APK assets — distribution stays with the F-Droid repo. Idempotent
(skips an existing release), gated on the test job only so notes appear
even when the F-Droid upload hiccups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:35:03 +02:00
e5b523e907 docs: backlog the Locations & People ideas (contact picker, OSM autocomplete)
Some checks failed
CI / ci (push) Has been cancelled
Captured from discussion, deliberately undetailed: permission-free contact
address picker, Photon-based address autocomplete (would need INTERNET —
explicit go/no-go on the no-network promise before any work), inline
contact suggestions, attendee editing as its own future milestone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:24:43 +02:00
d028b70e6e release: cut v2.0.0 — write support complete
Some checks failed
CI / ci (push) Failing after 1m7s
Build and Release to F-Droid / ci (push) Successful in 5m47s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m58s
Version bumped to 2.0.0 / 13. No code changes beyond the version — 2.0.0
closes out Milestone 2 (write support, v1.1 through v2.0): the final slice
is the save-conflict dialog (external change → overwrite/discard, external
delete → informational close), plus the store refresh: descriptions and
README describe write support and reminders, and fastlane screenshots
(DE+EN, six each) ship for F-Droid. CHANGELOG [2.0.0] carries the details.

Quick-add was cut from scope (the prefilled form covers it); calendar
switching while editing moved to the v3 backlog. Both documented in the
roadmap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:15:50 +02:00
626623bb6e feat(edit): conflict dialog on save + store metadata refresh (v2.0)
No locking (plan 03, decision 5): openForEdit keeps an EditSnapshot — the
prefilled form plus the raw Events-row times, which the form itself can't
see (it derives its times from the tapped occurrence, so an externally
moved event would otherwise stay invisible). Right before writing,
performSave re-reads the event and compares snapshots: a mismatch parks
the save in SaveUiState.AwaitingConflict carrying the already-chosen
recurring scope, and the dialog offers overwrite / discard / cancel
(OptionCard style). Overwrite still writes only dirty fields, so external
changes to untouched fields survive either way. A deleted event lands in
SaveUiState.Gone — an informational dialog that closes form and detail.
Fields the form can't write (attendees, status, self response, reminder
methods) are excluded from the comparison so sync noise can't fake a
conflict. The load-time zone is pinned in the EditTarget so a device
timezone change mid-edit can't either.

Store metadata: F-Droid descriptions (DE+EN) and the README stop claiming
read-only and now describe write support and reminder delivery. New
fastlane phoneScreenshots (6 per locale: week/month/day/detail/form/
reminder onboarding), captured on-device against demo-only calendars.

Tests: EditSnapshot equality (unchanged event, field change, row-time move
the form can't see, non-writable changes stay quiet).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:14:27 +02:00
264b2a86c1 release: cut v1.4.0 — reminder notifications
All checks were successful
CI / ci (push) Successful in 8m7s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m40s
Build and Release to F-Droid / ci (push) Successful in 2m3s
Version bumped to 1.4.0 / 12. No code changes beyond the version — 1.4.0 is
the reviewed-and-approved reminder slice: the EVENT_REMINDER receiver posting
due CalendarAlerts on a dedicated channel, tap-to-detail, the one-time
onboarding step requesting POST_NOTIFICATIONS with the duplicate-reminders
warning, and the Settings mirror. CHANGELOG [1.4.0] carries the details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:24:10 +02:00
b03bd67678 feat(reminders): reminder notifications — EVENT_REMINDER receiver, onboarding step, settings toggle (v1.4)
Calendula now posts event reminders itself (the Etar model): the provider
schedules the alarms and broadcasts EVENT_REMINDER, but a calendar app must
turn them into visible notifications — essential for users whose only
calendar app this is. A manifest-registered, exported receiver (data scheme
content://com.android.calendar) wakes us at reminder time; no foreground
service, no own alarm scheduling.

Delivery path (data/reminders/): EventReminderReceiver (Hilt, goAsync) →
ReminderAlertStore queries CalendarAlerts for STATE_SCHEDULED rows with
ALARM_TIME <= now → ReminderNotifier posts one notification per alert on a
dedicated high-importance channel, then best-effort marks rows FIRED
(needs WRITE_CALENDAR; without it a re-broadcast silently replaces — tag
per alert + setOnlyAlertOnce). Swiped notifications never return: FIRED
rows are never re-queried, so no dismiss-intent machinery. Research
(AOSP CalendarAlarmManager): the provider creates alert rows only for
METHOD_ALERT reminders, so the email-reminder filter happens upstream.

Tapping opens the event's detail screen: MainActivity is singleTop now,
parses eventId/begin/end extras (onCreate + onNewIntent) into Compose
state, and CalendarHost consumes the key exactly like an event tap.

Onboarding gained a one-time second step after the calendar grant (shared
OnboardingScaffold extracted from PermissionScreen): explains delivery,
warns that a second calendar app with notifications on duplicates
reminders, requests POST_NOTIFICATIONS (dialog on API 33+ only; minSdk 29).
"Not now" turns the feature off; reminders default ON. Settings mirrors
the toggle in a new Notifications section with the duplicate hint, and
re-requests the permission when enabling. Strings DE+EN.

Deliberately deferred (roadmap): snooze/dismiss actions, BOOT_COMPLETED /
exact-alarm scheduling, battery-exemption prompts.

Tests: reminderTimeText (all-day UTC-midnight reading, exclusive end day,
midnight-crossing ranges), reminders/onboarding pref round-trips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:23:34 +02:00
301f105fbc release: cut v1.3.0 — event edit
All checks were successful
CI / ci (push) Successful in 8m4s
Build and Release to F-Droid / ci (push) Successful in 2m0s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m42s
Version bumped to 1.3.0 / 11. No code changes beyond the version — 1.3.0 is
the reviewed-and-approved edit slice: shared form for editing, scope-at-save
for recurring events (this / this and following / all, exception rows and
series splits), three-way recurring delete, simple recurrence picker with
weekly weekday toggles, and the stale-instances split fix. CHANGELOG [1.3.0]
carries the details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:57:44 +02:00
f0e2e12939 feat(edit): event editing — shared form, scoped recurring writes, recurrence picker (v1.3)
The create form (v1.2) now edits: a pencil on the detail screen (writable
calendars only, contextual WRITE upgrade like delete) opens it prefilled via
EventDetail.toEditForm; populated sections always show, the calendar is
fixed, and a dirty-check writes only changed columns (pristine saves are
no-ops). Saving a dirty recurring event parks in SaveUiState.AwaitingScope
and asks how far the change reaches (Google model): "only this event" =
modified-occurrence exception via CONTENT_EXCEPTION_URI (empty optionals as
explicit NULLs since the provider clones the parent row), "this and all
following" = series split (insert new event first, then truncate), "all
events" = series-row update with the time delta applied to the series
DTSTART. A changed rule drops the exception option. Delete gained the same
middle scope.

Recurrence: EventForm.rrule + SimpleRecurrence (FREQ/INTERVAL/UNTIL/COUNT +
weekly BYDAY with locale-ordered weekday toggles) behind a picker on create
and edit; unrepresentable rules render humanized (shared ui/common
RecurrenceText) and survive verbatim. UNTIL validation flags rules ending
before the event starts.

Provider lessons baked in (verified on-device via adb probes): instance
caches regenerate only from an update's own values, so truncation sends the
full time-column set (truncateSeries) — RRULE-only updates left a stale
duplicate occurrence on the split day; UNTIL is written as the local end of
day in UTC (toRRule(zone), previousLocalDayEndUtcMillis) so UTC+x zones
can't leak an extra day. Reminder edits reconcile against actual provider
rows, keeping untouched rows' methods.

Tests: RecurrenceTest (parse/render/round-trip, truncation), update/exception
mapper paths, repository pass-throughs, prefill + populatedFields, raw-title
mapper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:57:32 +02:00
bdedf47972 release: cut v1.2.1 — event-form polish
All checks were successful
Build and Release to F-Droid / ci (push) Successful in 2m5s
CI / ci (push) Successful in 7m59s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m36s
Version bumped to 1.2.1 / 10. No code changes beyond the version — 1.2.1 is
the reviewed-and-approved form polish: card design system, optional fields
with settings defaults, reworked reminders, OptionCard dialogs app-wide,
expressive theme on standard springs, direction-aware today jump, IME fix.
CHANGELOG [1.2.1] carries the details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:41:11 +02:00
a69be3da43 feat(edit): form redesign, optional fields, OptionCard dialogs, expressive motion
All checks were successful
CI / ci (push) Successful in 5m56s
Post-v1.2.0 design iteration on the event form, reviewed slice by slice
on-device:

- Form rebuilt on the detail screen's card system: tonal EditCards with
  gutter icons (centred on the first row, top-aligned for multiline),
  borderless inline fields (placeholders at half opacity), calendar-coloured
  title accent, no dividers, bare top bar
- Optional sections (location, description, reminders, availability,
  visibility) with per-user defaults in Settings ("New event form" toggles);
  hidden ones unfold via a "More fields" picker dialog
- Reminders: stacked rows + full-width borderless add; two-step picker
  (one-tap presets, then custom amount + minutes/hours/days/weeks dropdown);
  written as METHOD_ALERT Reminders rows. Availability busy/free segmented
  toggle; visibility selector with per-level icons
- OptionCard (ui/common) is now the app-wide selection-dialog standard;
  calendar picker, visibility, more-fields, reminder presets and the
  recurring-delete chooser all use it — radio-row dialogs removed
- MaterialExpressiveTheme with MotionScheme.standard() (expressive bounce
  felt overdone); FAB stack + field reveals animate on theme springs;
  jump-to-today slides toward today's actual direction
- IME: adjustResize + imePadding so the keyboard never pans the form
- Tests: form-field prefs round-trips, availability/access provider
  mappings; DE+EN strings throughout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:14:30 +02:00
779fa1d480 release: cut v1.2.0 — event creation
All checks were successful
CI / ci (push) Successful in 7m47s
Build and Release to F-Droid / ci (push) Successful in 2m5s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m34s
Version bumped to 1.2.0 / 9. No code changes beyond the version — 1.2.0 is
the create slice: event form, "+" FAB on every view, last-used-calendar
preselect, provider-correct all-day storage. CHANGELOG [1.2.0] carries the
details; ROADMAP/STATE mark slice v1.2 shipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:27:17 +02:00
c59a071b82 feat(write): event creation — form screen, FAB, last-used calendar (v1.2)
Second slice of milestone 2 (write support):

- EventForm domain model + problems() validation (end-before-start,
  no-calendar; blank titles and instant events stay legal)
- Full-screen EventEditScreen: title, all-day switch, M3 date/time pickers
  (moving the start preserves the duration), calendar picker limited to
  writable calendars, location, description. Save validates, requests the
  WRITE upgrade contextually, and closes on success
- Calendar preselection: explicit pick > last-used (CalendarPrefs) > first
  writable calendar
- insertEvent in the data source; EventWriteMapper (JVM-tested) normalises
  all-day events to UTC midnights with exclusive DTEND, timed events to the
  device zone
- CalendarFabColumn shared by month/week/day: persistent "+" FAB anchored on
  the visible day, jump-to-today pill stacked above it
- Tests: EventForm validation, write-time mapping (incl. DST-safe epoch
  check), repository createEvent delegation/error propagation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:27:08 +02:00
285bfd90a7 release: cut v1.1.0 — event delete (write foundation)
All checks were successful
CI / ci (push) Successful in 7m28s
Build and Release to F-Droid / ci (push) Successful in 2m1s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m17s
Version bumped to 1.1.0 / 8. No code changes beyond the version — 1.1.0 is
the write-foundation slice: WRITE_CALENDAR, read-only-calendar detection,
and event delete (whole series or single occurrence). CHANGELOG [1.1.0]
carries the details; ROADMAP/STATE mark slice v1.1 shipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:55:56 +02:00
9529f19c60 feat(write): event delete + WRITE_CALENDAR foundation (v1.1)
First slice of milestone 2 (write support), per the new plan in
docs/superpowers/plans/2026-06-11-03-write-support.md:

- Delete from the event detail screen with confirmation; recurring events
  choose "only this event" (cancelled exception via CONTENT_EXCEPTION_URI,
  series survives) or "all events in the series" (Events-row delete)
- WRITE_CALENDAR in the manifest; onboarding requests read+write in one
  system dialog but only read gates the app — declining write keeps it
  usable read-only. v1.0 installs get a contextual write request on their
  first delete
- CALENDAR_ACCESS_LEVEL is read into CalendarSource.canModifyContents;
  read-only calendars (WebCal, birthdays, …) show no write actions. The
  no-op placeholder Edit button is removed until edit ships (v1.3)
- Onboarding copy drops the now-false "read-only" claim (DE+EN)
- Tests: repository delete delegation/error propagation, access-level
  mapping; FakeCalendarDataSource grows write ops

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:55:15 +02:00
0013c9f3b1 ci: cut redundant per-run work (cache fix companion, emulator skip, daemon reuse)
All checks were successful
CI / ci (push) Successful in 14m34s
- skip setup-android's default packages (pulled the ~300 MB emulator every run)
- drop unused platforms;android-36 and the dead jq install step
- cache /opt/android-sdk and ~/.gradle (release.yaml had no cache at all)
- drop --no-daemon so lint/test/assemble reuse one warm daemon per job
- Trivy scan only on main (advisory-only; was ~25s tax on every branch push)
- concurrency group cancels superseded runs; drop duplicate pull_request trigger

Companion to the act_runner fix on the CI host: job containers now join the
runner's network so the actions/cache server is reachable (saves previously
failed with reserveCache timeouts, so no cache was ever stored).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:50:01 +02:00
bd6ad4ae5f Merge pull request 'feat: full event read (v0.6.0) + onboarding redesign, cut v1.0.0' (#2) from feat/full-event-read-v0.6.0 into main
Some checks failed
CI / ci (push) Has been cancelled
2026-06-11 07:28:16 +00:00
141 changed files with 14259 additions and 1042 deletions

View File

@@ -6,7 +6,11 @@ on:
- '**'
tags-ignore:
- '**'
pull_request:
# Cancel superseded runs on the same branch.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
@@ -26,30 +30,25 @@ jobs:
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
# Default ("tools platform-tools") drags in the Android Emulator
# (~300 MB) which the build never uses.
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
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
"platform-tools" \
"platforms;android-36" \
"platforms;android-37.0" \
"build-tools;36.0.0"
- name: Install jq
run: |
set -e
SUDO=""
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update
$SUDO apt-get install -y jq
elif command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache jq
fi
- name: Setup Gradle cache
uses: actions/cache@v4
with:
@@ -63,16 +62,19 @@ jobs:
- name: Grant execute permission for gradlew
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)
run: ./gradlew lintDebug --no-daemon
run: ./gradlew lintDebug
- name: Unit tests
run: ./gradlew testDebugUnitTest --no-daemon
run: ./gradlew testDebugUnitTest
- name: Assemble debug APK
run: ./gradlew assembleDebug --no-daemon
run: ./gradlew assembleDebug
- name: Trivy filesystem scan
if: github.ref == 'refs/heads/main'
run: |
set -e
SUDO=""

View File

@@ -1,4 +1,4 @@
name: Build and Release to F-Droid
name: Release F-Droid repo + Gitea release
on:
push:
@@ -24,16 +24,33 @@ jobs:
- 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
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
"platform-tools" \
"platforms;android-36" \
"platforms;android-37.0" \
"build-tools;36.0.0"
- 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
@@ -42,10 +59,10 @@ jobs:
# any tag-resolved drift (e.g. version code substitution issues).
- name: Unit tests
run: ./gradlew testDebugUnitTest --no-daemon
run: ./gradlew testDebugUnitTest
- name: Assemble debug APK (sanity)
run: ./gradlew assembleDebug --no-daemon
run: ./gradlew assembleDebug
build-and-deploy:
needs: ci
@@ -65,16 +82,33 @@ jobs:
- 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
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
"platform-tools" \
"platforms;android-36" \
"platforms;android-37.0" \
"build-tools;36.0.0"
- 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: Install jq
run: |
set -e
@@ -87,7 +121,12 @@ 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/')
run: |
set -e
RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
@@ -101,8 +140,12 @@ jobs:
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"
- name: Setup Android Keystore
if: startsWith(github.ref, 'refs/tags/')
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
@@ -121,7 +164,8 @@ jobs:
run: chmod +x ./gradlew
- name: Build release APK
run: ./gradlew assembleRelease --no-daemon
if: startsWith(github.ref, 'refs/tags/')
run: ./gradlew assembleRelease
- name: Setup F-Droid Server Tools
run: |
@@ -131,29 +175,48 @@ jobs:
$SUDO apt-get install -y sshpass python3-pip
pip3 install --break-system-packages --upgrade fdroidserver
- name: Initialize or fetch F-Droid Repository
- name: Fetch existing F-Droid repo from Hetzner
env:
HOST: ${{ secrets.HETZNER_HOST }}
USER: ${{ secrets.HETZNER_USER }}
PASS: ${{ secrets.HETZNER_PASS }}
run: |
set -euo pipefail
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=20"
mkdir -p fdroid
sshpass -p "$PASS" sftp -o StrictHostKeyChecking=no "$USER@$HOST" <<'SFTP'
-mkdir dev
-mkdir dev/fdroid
-mkdir dev/fdroid/repo
SFTP
sshpass -p "$PASS" scp -o StrictHostKeyChecking=no -r "$USER@$HOST:dev/fdroid/." fdroid/ || (cd fdroid && fdroid init)
# Pull only the published repo/ (all apps' APKs), any per-app
# metadata, and the repo icon — enough to rebuild the index without
# dropping the other apps. The signing key is deliberately NOT pulled
# from the box; it comes from CI secrets in the next step so it never
# has to live in the web-served tree.
sshpass -p "$PASS" scp $SSH_OPTS -r "$USER@$HOST:dev/fdroid/repo" fdroid/ 2>/dev/null || true
sshpass -p "$PASS" scp $SSH_OPTS -r "$USER@$HOST:dev/fdroid/metadata" fdroid/ 2>/dev/null || true
sshpass -p "$PASS" scp $SSH_OPTS "$USER@$HOST:dev/fdroid/icon.png" fdroid/ 2>/dev/null || true
mkdir -p fdroid/repo fdroid/metadata
- name: Ensure F-Droid repo signing key and icon
- name: Restore F-Droid signing key and config from secrets
env:
FDROID_KEYSTORE_BASE64: ${{ secrets.FDROID_KEYSTORE_BASE64 }}
FDROID_CONFIG_BASE64: ${{ secrets.FDROID_CONFIG_BASE64 }}
run: |
cd fdroid
mkdir -p repo/icons
if [ ! -f keystore.p12 ]; then
fdroid update --create-key
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.)
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
exit 1
fi
echo "$FDROID_KEYSTORE_BASE64" | base64 --decode > fdroid/keystore.p12
echo "$FDROID_CONFIG_BASE64" | base64 --decode > fdroid/config.yml
test -s fdroid/keystore.p12
test -s fdroid/config.yml
mkdir -p fdroid/repo/icons
- name: Copy new APK to repo
if: startsWith(github.ref, 'refs/tags/')
run: |
set -e
mkdir -p fdroid/repo
@@ -169,12 +232,33 @@ jobs:
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"
- name: Generate F-Droid Index
run: |
cd fdroid
fdroid update -c
- name: Upload Repo to Hetzner
- name: Upload repo/ to Hetzner
env:
HOST: ${{ secrets.HETZNER_HOST }}
USER: ${{ secrets.HETZNER_USER }}
@@ -185,6 +269,113 @@ jobs:
sshpass -p "$PASS" sftp $SSH_OPTS "$USER@$HOST" <<'SFTP'
-mkdir dev
-mkdir dev/fdroid
-mkdir dev/fdroid/repo
SFTP
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/. "$USER@$HOST:dev/fdroid/"
# 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.
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
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="${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.
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
import json, sys
print(json.dumps({
"tag_name": sys.argv[1],
"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.
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)
if [ -n "$ID" ]; then
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @payload.json "$API/releases/$ID")
OK=200
else
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @payload.json "$API/releases")
OK=201
fi
cat response.json
if [ "$CODE" != "$OK" ]; then
echo "Release upsert failed with HTTP $CODE (expected $OK)"
exit 1
fi

View File

@@ -0,0 +1,41 @@
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:
push:
branches:
- '**'
tags-ignore:
- '**'
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

4
.gitignore vendored
View File

@@ -40,6 +40,7 @@ captures/
# Keystore files
*.jks
*.keystore
*.p12
/key.properties
# Google Services (e.g. APIs or Firebase)
@@ -50,8 +51,7 @@ google-services.json
Thumbs.db
# F-Droid local artifacts (the pipeline generates them in CI)
fdroid/repo/
fdroid/keystore.p12
/fdroid/
# KSP
.ksp/

View File

@@ -2,11 +2,12 @@
## What This Is
A modern Material 3 Expressive Android calendar app, read-only V1. Lives
entirely on top of Android's `CalendarContract` — any calendar synced to the
device (CalDAV via DAVx5, Google, local, WebCal, …) shows up automatically.
The differentiator is visual: real Material 3 Expressive design that no
existing FOSS calendar app delivers.
A modern Material 3 Expressive Android calendar app. Lives entirely on top
of Android's `CalendarContract` — any calendar synced to the device (CalDAV
via DAVx5, Google, local, WebCal, …) shows up automatically; creating,
editing, and deleting writes straight back, and reminders are delivered by
the app itself (Etar model). The differentiator is visual: real Material 3
Expressive design that no existing FOSS calendar app delivers.
## Core Value
@@ -15,8 +16,10 @@ re-inventing the calendar sync stack — leave that to DAVx5 and the system.
## Current Milestone
**v0.1 — Foundation & CI:** Buildable Android project scaffold with theme,
icon, i18n, Hilt, DataStore, green CI.
Milestones 1 (read, v1.0) and 2 (write support, v1.1v2.0.0 incl. reminder
delivery) are **complete** — v2.0.0 shipped 2026-06-11. Next is v3.0
(power-user features) plus an undecided "Locations & People" idea backlog;
see `ROADMAP.md`.
## Stack
@@ -26,9 +29,8 @@ Expressive 1.5.0-alpha21 (alpha is intentional — Expressive APIs only
live in the 1.5 alpha line). Hilt 2.59.2, DataStore. Gradle Kotlin DSL
with Version Catalog. AGP 9.1.1, Gradle 9.5.1. JVM target 17.
Read-only V1, write support V2.
Android-only (minSdk 29, targetSdk 36). No iOS.
Android-only (minSdk 29, targetSdk 36). No iOS. No `INTERNET` permission —
any feature that would need one is an explicit product decision first.
## Naming

View File

@@ -2,36 +2,43 @@
See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
## V1 Scope (Variant "B")
## V1 Scope (Variant "B") — shipped in full (v1.0.0, 2026-06-11)
### Validated (shipped)
- Foundation & CI infrastructure — v0.1.0 (2026-06-08)
### Active (V1)
- [x] Foundation & CI infrastructure
- [x] Foundation & CI infrastructure — v0.1.0 (2026-06-08)
- [x] Data Layer over `CalendarContract`
- [x] Permission flow (`READ_CALENDAR`)
- [ ] Month view (S1)
- [ ] Week view (S2)
- [ ] Day view (S3)
- [ ] Event Detail Sheet (S4)
- [ ] Multi-Calendar Filter (M3)
- [x] Month view (S1)
- [x] Week view (S2)
- [x] Day view (S3)
- [x] Event Detail Sheet (S4) — became a full screen, plus full event read (v0.6)
- [x] Multi-Calendar Filter (M3)
- [x] Today button (M2) — shipped v0.5; Jump-to-Date **cut from scope**
- [ ] View-Switcher (M1)
- [ ] Settings screen (M4)
- [ ] Empty / no-permission / no-calendars states
- [ ] German + English localization
- [ ] Loading/Failure/Success states per screen (architectural pattern)
- [x] View-Switcher (M1)
- [x] Settings screen (M4)
- [x] Empty / no-permission / no-calendars states
- [x] German + English localization
- [x] Loading/Failure/Success states per screen (architectural pattern)
### Out of Scope (V2+)
## V2 Scope — write support, shipped in full (v2.0.0, 2026-06-11)
- [x] Write foundation: `WRITE_CALENDAR`, read-only-calendar detection, delete (v1.1)
- [x] Create event: form, FAB, last-used calendar (v1.2; polish v1.2.1)
- [x] Edit event: shared form, scoped recurring writes, recurrence picker (v1.3)
- [x] Reminder notifications (v1.4) — **reversal of the original
"system handles reminders" assumption:** Calendula targets
sole-calendar-app users, so it posts reminder notifications itself
(Etar model), incl. `POST_NOTIFICATIONS` onboarding
- [x] Conflict dialog on save + store polish (v2.0)
- Quick-add — **cut from scope** (the prefilled form covers it)
- Calendar switching while editing — moved to v3 backlog
### Out of Scope (V3+)
- Event create / edit / delete (V2)
- Home-screen widget
- Full-text search
- Quick-add
- Custom notifications/reminders (system already handles these)
- Tablet/foldable-specific layouts
- Locations & People ideas (contact picker, OSM autocomplete) — see
`ROADMAP.md` idea backlog, undecided
- iOS support (Android-only by design)
## Constraints

View File

@@ -53,17 +53,408 @@ after v0.6 (full event read) plus the onboarding-screen polish pass.
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
## v2.0 — Write Support
## v2.0 — Write Support (complete, shipped 2026-06-11)
- Event create / edit / delete via `CalendarContract` writes
- Quick-add sheet
- Conflict UX (event modified externally during edit)
Delivered in four releasable slices (plan:
`docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a
guide here, not a contract — scope per slice is decided as we go.
## v3.0 — Power-User Features
| Version | Milestone | Status |
|---|---|---|
| v1.1 | Write foundation — `WRITE_CALENDAR`, read-only-calendar detection, delete (series + single occurrence) | complete (shipped 2026-06-11) |
| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) |
| v1.4 | Reminder notifications — see below | complete (shipped 2026-06-11) |
| v2.0 | Conflict dialog, polish pass (store copy refresh, F-Droid screenshots), release | complete (shipped 2026-06-11) |
- Home-screen widget
- Full-text search
- Tablet / foldable layouts
- Optional: ICS file import (drag-and-drop)
v2.0 scope was re-cut on 2026-06-11, after v1.4:
- **Occurrence edit** already shipped early, in v1.3.
- **Quick-add** is **cut from scope**: the full form already opens prefilled
(visible day, last-used calendar, optional fields hidden), so the sheet
would only save one screen transition while adding a second create-surface
to maintain. Revisit only if real-world feedback says creation feels heavy.
- **Calendar switching while editing** moves to the v3 backlog (sync-adapter
minefield: `CALENDAR_ID` is sync-adapter-owned, AOSP locks the field; an
honest implementation is copy+delete like Google Calendar, with sync-identity
and attendee side effects).
- **Conflict dialog** stays (plan 03, decision 5): on save, compare against
the row as it was when the form loaded; on external change, ask
overwrite / discard. Closes the silent-clobber gap on synced calendars.
Order is indicative — community feedback after V1 may re-prioritize.
## v1.4 — Reminder Notifications
**Essential**, not nice-to-have: Calendula targets users for whom it is their
*only* calendar app, so reminder delivery can't be delegated to Google/OEM
Calendar. The calendar provider schedules reminders and broadcasts
`android.intent.action.EVENT_REMINDER`, but it does **not** post the visible
notification — a calendar app must. We become that app (the Etar model).
Scope:
- Manifest-registered `BroadcastReceiver` for `EVENT_REMINDER`
(data scheme `content://com.android.calendar`) — wakes us at reminder time,
no foreground service.
- Read `CalendarContract.CalendarAlerts` / `Reminders`, filter to
`METHOD_ALERT` / `METHOD_DEFAULT` (skip `METHOD_EMAIL`); post on a dedicated
notification channel; tap opens event detail.
- `POST_NOTIFICATIONS` runtime permission (API 33+) — requested in onboarding.
- Onboarding step: (a) request `POST_NOTIFICATIONS`, (b) in-app reminders
toggle, **default ON**, with copy warning that a second calendar app with
notifications on will cause duplicate reminders. Mirrored into Settings
(reversible).
Deliberately deferred (add only if needed):
- Snooze / dismiss notification actions (Etar has them)
- Battery-optimization exemption prompt for delivery reliability
## v2.1 — Month event grid + drawer view tabs (shipped 2026-06-15)
- Month grid shows real events as continuous multi-day bars (not just dots)
- View section in the navigation drawer to switch Month / Week / Day
- Fix: text cursor no longer jumps in event text fields
## v2.2 — Tap-to-create + local calendar management (shipped 2026-06-16)
- Tap an empty slot in day/week → create form prefilled with that day + the
tapped hour (snapped to the hour, 1 h long)
- Local (device-only) calendar management in a full-screen editor from
Settings → Calendars: create / rename / recolor / delete, with name,
pastel-previewed colour, and description (stored in `CAL_SYNC1`)
- Synced calendars listed read-only, grouped by account, each with a
per-account "manage in source app" deep-link (resolved from the account's
authenticator — DAVx5/ICSx5/…) + an add-account shortcut
- Shared `InlineTextField` extracted to `ui.common` (event form + calendar
editor share one input style)
## v2.3 — Material 3 grouped-list redesign (shipped 2026-06-16)
A structural + visual pass adopting one shared blueprint (modelled on the ReFra
gallery app) across Settings, the calendar manager and the navigation drawer.
- Shared `ui/common/GroupedList.kt`: `CollapsingScaffold` (a `LargeTopAppBar`
whose title collapses on scroll) + `GroupedRow` (Position-based corner
grouping, press-animated corners, `selected` + `minHeight` knobs).
- Settings: category hub with About card on top and sliding sub-pages
(Appearance / New event form / Notifications); theme/week-start/language
pickers moved from `DropdownMenu` to OptionCard dialogs; token-based icon
chips; `ic_gitea.xml` for the About "Source" button.
- Calendar manager + drawer restyled to match; shared `CalendarColorChip`;
drawer scrolls as one with the active view highlighted.
- Cards use `surfaceContainerHigh` for readable contrast.
- Donate button on the About card deferred (target TBD).
---
# Backlog (theme-based, post-v2.1)
The old v3.0 / "daily-driver polish" / "Locations & People" lists are
consolidated here by theme. Within a group, **(in progress)** /
**(next)** mark what is being or about to be worked; everything else is an
approved-but-unscheduled idea unless tagged **(idea)** /
**(go/no-go)** / **(rejected)**. Order across groups is not a commitment.
## Near-term sequence (ranked, 2026-06-16)
The theme groups below are the full menu; this is the committed *order* for
the next stretch. Ranking favours finishing the current create/edit + calendar
arc before opening new fronts, then cheap-relative-to-value items and ones that
unblock a later item. Order is a plan, not a contract — revisit after each lands.
**Tier 1 — finish the current arc (create/edit + calendars)**
1. Tap-to-create in day/week *(shipped v2.2.0)* — prefilled create from an empty slot
2. Local calendar management + "manage in source app" deep-links *(shipped v2.2.0)*
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~~ *(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.)
### Settings redesign & restructure *(shipped v2.3.0)*
The original scope below is kept as a record; the implementation expanded from a
sub-screen restructure into the shared grouped-list blueprint (see "v2.3" above).
The settings screen has grown into a flat vertical scroll of divider-separated
sections (Appearance, Event form, Notifications, Calendars, Language, About) and
will keep accreting rows (per-event-color defaults, default reminder, more
calendar entries are all queued). It needs structure before it gets unwieldy.
**Decided (2026-06-16): sub-screens**, not flat-but-carded. The top level
becomes a category list; each category opens its own destination. More
M3-idiomatic for a settings surface that will keep growing, and it mirrors the
existing Calendars row, which already navigates out to its own screen.
Structure — top-level settings list → category destinations:
- **Appearance** → theme, dynamic colour, week start
- **Event form** → the 6 default-field toggles + the hint text
- **Notifications** → reminders toggle (POST_NOTIFICATIONS flow stays)
- **Calendars** → already its own screen (`CalendarsScreen`); just becomes a
peer category row, no change to that screen
- **Language** → single control; keep as a top-level row that opens an
OptionCard directly (a whole sub-screen for one choice is overkill)
- **About** → kept inline on the top-level list as a card (read-only info,
not worth a navigation hop). Card layout, top → bottom:
- **Identity** — app logo + name "Calendula", with "by Jean-Luc Makiola"
as a subtitle beneath the name
- **Action buttons** (small, button-styled, sit in a row):
- **Source** — Gitea logo, opens the repo (`about_source_url`)
- **License** — opens the LICENSE file on Gitea
- **Donate** *(tentative)* — sits next to Source; target TBD (decide
before building: Liberapay / Ko-fi / Gitea sponsor / etc.)
- **Version** — small version number at the bottom of the card
Scope:
- **Navigation** — add the settings sub-screen destinations alongside the
existing settings/calendars routes in `CalendarHost`; back pops to the
settings list (mind the existing `BackHandler` that guards against falling
through to the activity).
- **Fix the dialog-pattern violation** — theme, week-start and language use
`DropdownMenu`; the project default is the full-width tonal OptionCard modal
(radio/dropdown/text-list dialogs are banned, see
`option-card-modal-style-default`). Migrate these selectors to OptionCard.
- **Visual pass** — top-level category rows with leading icons; consistent
spacing and row affordances aligned with the event-form card design system.
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~~ *(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~~ *(done, v2.5.0 — agenda + month widgets)*
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; optional quick-settings tile still open
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
9. **Reminders — defaults + delivery reliability** *(next)* — global default
reminder **+ per-calendar override**, bundled with exact-alarm / battery
hardening. Elevated above .ics: it's core to the "Calendula is your only
calendar app" promise. Full sketch in "Reminders — defaults & delivery
reliability" below.
10. **Local-calendar backup / export** — device-only calendars have no sync and
therefore **no backup**; losing the phone = total data loss. Whole-calendar
`.ics` export + restore. A data-integrity gap, not a feature; front-runs and
overlaps the single-event .ics work below.
11. Share event as .ics + receive/open .ics into a prefilled create form
12. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog)
13. Snooze / dismiss notification actions — follows the reminders slice (#9)
**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
- Move event to another calendar — sync-adapter minefield (copy+delete model)
**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
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
full-text search, ICS file import. Pulled in opportunistically, not sequenced.
Debatable calls worth a second look: whether **local-calendar backup (#10)**
should lead Tier 4 outright (it's a silent data-loss risk, not a feature);
whether drag-drop (#12) jumps ahead given its daily-driver impact.
## Navigation & views
- ~~Tap an empty slot in day/week → create form prefilled with that
date+time, snapped to the hour~~ **shipped v2.2.0** (long-press variant
not added — single tap covers it)
- 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 — standard in every calendar, cheap,
currently absent. Daily-driver polish.
- Week numbers in the **month** grid — week view already shows the badge
(`WeekNumberBadge`, `WeekScreen.kt`); extend to month for ISO/European users.
- Pinch-to-zoom time scale in day/week
- Tablet / foldable layouts *(was v3.0)*
- Full-text search *(was v3.0)* — promote out of "fill-in": 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.
## Calendars & accounts
- ~~Create / manage local (device-only) calendars~~ **shipped v2.2.0**
name + color + description; rename / recolor / delete the calendars the app
owns. Inserted under `ACCOUNT_TYPE_LOCAL` as a sync adapter; description in
`CAL_SYNC1`. Full-screen "Calendars" editor reached from Settings.
- ~~Per-calendar "manage in source app" deep-link~~ **shipped v2.2.0** — for
synced calendars, open the app the calendar actually came from based on
its `ACCOUNT_TYPE` (DAVx5 `bitfire.at.davdroid`, Google `com.google`,
…); fall back to system account/sync settings. Plus an "add account"
entry into system Accounts. Honest boundary for remote calendars.
- **Remote calendar create/edit** *(go/no-go)* — creating a CalDAV
collection (`MKCALENDAR`) or a Google calendar means an in-app sync
client: **INTERNET permission, credential storage, the full server
round-trip** — i.e. re-implementing DAVx5. DAVx5 exposes no public
intent to delegate the create to it. Cosmetic local edits (color/name)
to an existing synced row are possible but don't propagate to the server
and may be overwritten on next sync — not promised. Same explicit
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)* — device-only
(`ACCOUNT_TYPE_LOCAL`) calendars are first-class in Calendula but have **no
sync and therefore no backup**: a lost/wiped phone destroys them permanently.
Whole-calendar `.ics` (VCALENDAR) export to a user-chosen file (SAF), plus
restore-on-import that recreates events into a chosen local calendar. Reuses
the .ics serializer from the single-event share work; the restore path reuses
the import parser. A data-integrity obligation, not a feature.
## Reminders — defaults & delivery reliability *(implemented 2026-06-17, `feat/default-reminders` — pending on-device review)*
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).
### Open decisions (resolve before building)
1. Self-schedule via `AlarmManager` vs trust the provider broadcast
(reliability vs simplicity + battery cost).
2. All-day reminder representation (minutes-before vs absolute time-of-day).
3. Where per-calendar overrides live in the UI (rows on the Calendars screen vs
a list inside the Notifications sub-page).
### Later (round two)
- Snooze + dismiss actions on the notification (snooze needs an
exact-alarm / WorkManager decision) — Tier 4 #13.
## 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)*
## Platform & launchers
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0**
optional quick-settings tile still open
## 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.
- **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
INTERNET permission** — first dent in the "no network access" promise;
if built: opt-in (off by default), honest copy, configurable endpoint
for self-hosters, onboarding footnote + F-Droid copy reworded. This
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).
## Consciously rejected
- Travel time / weather / smart suggestions (network, core-promise conflict)
- 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)

View File

@@ -1,13 +1,17 @@
# Calendula — Current State
*Last updated: 2026-06-11*
*Last updated: 2026-06-17*
## Status
**Milestone:** v1.0.0 — First public release (shipped 2026-06-11)
**Phase:** V1 is complete and released. All screens done, the read model
surfaces every readable `CalendarContract` field, and the onboarding screen
got its Material 3 Expressive polish pass. Next horizon is v2.0 (write support)
**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;
v2.4.0 (per-event colors) and v2.5.0 (jump-to-date, Agenda view, home-screen
agenda + month widgets, and a "New event" launcher shortcut) shipped
2026-06-17. The backlog is now organised by theme in `ROADMAP.md`.
## Progress
@@ -28,7 +32,99 @@ got its Material 3 Expressive polish pass. Next horizon is v2.0 (write support)
URLs in the detail view; new domain enums + mapper unit tests. (A dedicated
URL field was cut — no `CalendarContract` column backs it.)
- [x] v1.1 write foundation — `WRITE_CALENDAR` (onboarding asks READ+WRITE,
only READ gates; contextual upgrade for v1.0 installs), read-only-calendar
detection (`CALENDAR_ACCESS_LEVEL``canModifyContents`, actions hidden for
WebCal/birthday calendars), delete from the detail screen (recurring:
"only this event" via cancelled exception / "all events in the series"),
repository + mapper tests
- [x] v1.2 create event — full-screen `EventEditScreen` (title, all-day,
M3 date/time pickers with duration-preserving start moves, writable-only
calendar picker preselecting the last-used calendar, location, description),
"+" FAB on all three views prefilled with the visible day, `insertEvent`
with provider-correct all-day normalisation (UTC midnights, exclusive end),
domain/mapper/repository tests
- [x] v1.3 edit event (shipped 2026-06-11) — `EventEditScreen` reused for
edit (detail-screen Edit action, `canModify`-gated, contextual WRITE
upgrade), dirty-checked partial `update` on the Events row (recurring:
series DTSTART moves by the user's delta, DURATION instead of DTEND),
reminder diff by minutes (kept rows keep their method), simple recurrence
picker (FREQ/INTERVAL/UNTIL/COUNT; complex RRULEs preserved verbatim and
shown humanized), `EventFormField.Recurrence` incl. settings default,
recurrence also available on create; domain/mapper/repository tests.
Review round 1: weekly BYDAY day-toggles in the custom picker ("every week
on Mon+Fri"). Review rounds 24: occurrence edit pulled forward from v2.0
and made three-way like delete ("this" = exception row via
`CONTENT_EXCEPTION_URI`, "this and following" = series split, "all" =
series update); delete equally three-way (truncation via RRULE UNTIL);
the edit-scope question moved to save time (Google model) — dirty
recurring saves park in `SaveUiState.AwaitingScope`, a changed rule drops
the "only this event" option
- [x] v1.4 reminder notifications (shipped 2026-06-11) — exported
`EVENT_REMINDER` receiver → `CalendarAlerts` (SCHEDULED & due) →
dedicated channel, tap opens detail (singleTop deep link); best-effort
FIRED marking; one-time onboarding step requesting `POST_NOTIFICATIONS`
with duplicate-reminders warning; Settings mirror. Provider only fires
`METHOD_ALERT` rows (AOSP-verified), so email reminders never reach us
- [x] v2.0 conflict dialog + store polish (shipped 2026-06-11 as v2.0.0) —
`EditSnapshot` compare on save (overwrite/discard; deleted → close),
quick-add cut, calendar-switch → v3 backlog; F-Droid/README copy
refreshed, fastlane screenshots DE+EN captured on-device
- [x] v2.1 (shipped 2026-06-15) — month grid shows real events as
continuous multi-day bars; navigation-drawer View section
(Month/Week/Day); cursor-jump fix in event text fields
- [x] v2.2 (shipped 2026-06-16) — tap an empty slot in day/week to create
(prefilled with that day + tapped hour, snapped to the hour); local
calendar management in a full-screen editor from Settings →
Calendars: create/rename/recolor/delete device-only calendars
(`ACCOUNT_TYPE_LOCAL`, sync-adapter insert) with name, pastel-previewed
colour, and description (stored in `CAL_SYNC1`); synced calendars listed
read-only grouped by account with a per-account "manage in source app"
deep-link (resolved from the account's authenticator: DAVx5/ICSx5/…) and
an add-account shortcut. Shared `InlineTextField` extracted to `ui.common`
- [x] v2.3 settings/calendars/drawer redesign (shipped 2026-06-16) — adopted a
shared Material 3 grouped-list blueprint, modelled on the ReFra gallery app
and extracted to `ui/common/GroupedList.kt` (`CollapsingScaffold` with a
`LargeTopAppBar` exit-until-collapsed title; `GroupedRow` with Position-based
corner grouping, press-animated corners, `selected` + `minHeight` knobs).
- Settings: category hub (About card on top → version mark at the foot) with
sliding sub-pages (Appearance / New event form / Notifications); token-
based icon chips; theme/week-start/language pickers migrated from
`DropdownMenu` to OptionCard dialogs. New `ic_gitea.xml` (Simple Icons,
verbatim path) for the About "Source" button; en+de strings.
- Calendar manager: same collapsing scaffold + grouped rows; shared
`CalendarColorChip` (neutral chip, pastelised calendar glyph).
- Navigation drawer: branded header, grouped View switcher (active view
highlighted via `secondaryContainer`), the filter list restyled to
grouped rows with a trailing checkbox; the whole drawer scrolls as one.
- 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.
## Next
1. v1.0.0 released — monitor the F-Droid build/publish
2. Plan v2.0 (write support: create / edit / delete, quick-add, conflict UX)
1. Monitor the F-Droid build/publish for the v2.4.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. **Duplicate event** and **jump-to-date** are the cheap follow-ups; then
agenda view (strategic, backs a future widget). Full ranked sequence in
`ROADMAP.md` → "Near-term sequence".

View File

@@ -7,6 +7,272 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
### Changed
- Redesigned Settings around the Material 3 grouped-list pattern: a large
title that collapses into the toolbar as you scroll, category cards on the
main screen, and dedicated sub-pages for Appearance, the new-event form, and
Notifications. The theme, week-start and language pickers now use the app's
standard option-card dialogs instead of dropdown menus
- About moved to the top of Settings as a card — app icon, author, and quick
links to the source code and licence — with the version shown plainly at the
foot of the list
- The Calendars screen now uses the same grouped-card layout and collapsing
title, and each calendar shows a soft pastel-tinted calendar glyph rather
than a plain colour swatch
- Redesigned the navigation drawer to match: a branded header, the
Month / Week / Day switch and your calendars as grouped cards (with the
active view highlighted), and the whole drawer now scrolls as one
## [2.2.0] — 2026-06-16
### Added
- Tap an empty slot in the day or week view to create an event there: the
create form opens prefilled with that day and the tapped hour (snapped to
the hour, one hour long). Tapping an existing event still opens it
- Local calendars: create and manage device-only calendars that live
entirely on this phone — no account, no sync — from a new "Calendars"
screen in Settings. Give each a name, a colour, and an optional
description; rename, recolour, or delete them later. Useful when you want
a calendar without setting up an account
- The Calendars screen also lists your synced calendars (DAVx5, ICSx5, …)
grouped by account, each with a "Manage" button that opens the app the
calendar actually comes from, plus an "Add account" shortcut to the
system account settings. Calendula never touches a synced calendar's
server itself — that stays with its own app
### Changed
- Colour swatches in the calendar editor now preview the soft, pastel tone
a calendar is actually drawn with, instead of a bright raw colour
- The calendar editor reuses the event form's field and button styling for
a consistent look
## [2.1.0] — 2026-06-15
### Added
- The month view now shows real events in each day instead of coloured
dots: all-day and multi-day events render as continuous bars at the top
(a multi-day event is one connected bar across the days it spans, not a
chip per day), with single-day timed events as filled pills beneath.
Up to three rows show per day, then a "+N" dot indicator for the rest.
Each day keeps a rounded surface background, matching the week and day
views; today is marked with a filled circle on its number
- The slide-out panel now has a "View" section to switch between Month,
Week, and Day, mirroring the top-bar switcher pill — tapping a view
selects it and closes the drawer. The current view is highlighted
### Fixed
- Typing in the event title, location, and description fields no longer
makes the cursor jump around: the form state's round-trip to the UI was
hopping to a background dispatcher, so the text field saw a lagging value
while typing. Only the calendar/preferences reads stay off the main
thread now; the keystroke path is synchronous again
## [2.0.0] — 2026-06-11
### Added
- Conflict handling when saving an edit: if the event changed elsewhere
(sync, another device) while the form was open, saving now asks whether
to keep or discard your changes instead of silently overwriting the
edited fields — and tells you when the event was deleted in the meantime.
"Keep" still writes only the fields you touched; external changes to
untouched fields survive either way
- F-Droid store screenshots (German + English), captured with demo data
### Changed
- F-Droid description and README no longer claim the app is read-only —
they now describe write support and reminder delivery
### Fixed
- `versionName`/`versionCode` bumped to 2.0.0 / 13 — closing out the
write-support milestone (v1.1 through v2.0)
## [1.4.0] — 2026-06-11
### Added
- Reminder notifications (v1.4): Calendula now delivers event reminders as
notifications itself — the system schedules them but posts nothing, so a
calendar app must (essential when Calendula is the only one installed).
Due reminders appear on a dedicated "Event reminders" channel; tapping one
opens the event's detail screen. Email reminders are never posted (the
provider only schedules alert-type reminders)
- A one-time onboarding step after the calendar grant introduces reminders,
requests the notification permission (Android 13+), and warns that a second
calendar app with notifications on will duplicate them. "Not now" leaves
the feature off
- Settings gained a "Notifications" section mirroring the choice: an event-
reminders toggle (default on) with the duplicate-reminders hint; turning it
on re-requests the notification permission when missing
### Fixed
- `versionName`/`versionCode` bumped to 1.4.0 / 12
## [1.3.0] — 2026-06-11
### Added
- Event editing: a pencil action on the detail screen (writable calendars
only) opens the event form prefilled with the event. Only fields you
actually changed are written back; saving an untouched form is a no-op.
Sections holding data are always shown, regardless of the form-field
defaults; the calendar itself can't be changed while editing
- Recurring events — scoped writes, chosen when saving (Google model):
"only this event" (a modified-occurrence exception), "this and all
following" (the series is split at the occurrence), or "all events in
the series". Changing the recurrence rule rules out "only this event"
- Deleting a recurring event gained the middle option too: "this and all
following events" ends the series just before the chosen occurrence
- Recurrence picker (create and edit): one-tap daily/weekly/monthly/yearly
presets plus a custom step with interval + unit, weekday toggles for
weekly rules ("every week on Mon and Fri"), and an end condition (never /
on a date / after a number of times). Rules the picker can't express
(e.g. "second Thursday monthly") are shown humanized and preserved
verbatim unless replaced. Recurrence also joined the optional form
fields and their settings defaults
- Validation: a repeat that would end before the event starts is flagged
(it would otherwise vanish from every view)
### Changed
- Editing reminders reconciles against the provider's actual rows:
reminders you didn't touch keep their method (e.g. email reminders on
synced events survive unrelated edits)
- The contextual WRITE_CALENDAR upgrade for v1.0 installs covers the edit
action like delete
### Fixed
- Splitting a series ("this and following") sends the complete time-column
set in one update, so the provider regenerates its cached instances — an
RRULE-only update left a stale duplicate of the tapped occurrence on the
split day
- RRULE UNTIL values are written as the local end of day expressed in UTC
(instead of a flat `T235959Z`), so recurrences can't leak an extra day in
timezones ahead of UTC
- `versionName`/`versionCode` bumped to 1.3.0 / 11
## [1.2.1] — 2026-06-11
### Added
- Optional event-form fields with user-controlled defaults: reminders,
availability (busy/free), and visibility (default/public/private/
confidential) joined location and description as form sections. Settings
gained a "New event form" section choosing which show by default; the rest
unfold via a "More fields" picker
- Reminders editor: stacked rows with right-bound remove, full-width add
action; the picker offers one-tap presets and a custom amount + unit
(minutes/hours/days/weeks) step
- `OptionCard` — the app's standard selection-dialog row (full-width tonal
card, optional icon + supporting line, highlighted selection). All dialogs
(calendar, visibility, more-fields, reminder presets, recurring-delete)
now use it; radio-row dialogs are retired
### Changed
- Event form redesigned onto the detail screen's design system: tonal cards
with gutter icons (top-aligned on tall cards), borderless inline text
fields, calendar-coloured accent bar under the title, no dividers, no
top-bar title; placeholders render clearly fainter than input
- M3 Expressive motion: the theme now provides a MotionScheme
(`MaterialExpressiveTheme`, standard springs — expressive bounce reviewed
as overdone), the FAB stack and "more fields" reveals animate on theme
springs
- The jump-to-today slide is direction-aware (future → today slides in from
the left, past → from the right)
- `versionName`/`versionCode` bumped to 1.2.1 / 10
### Fixed
- The keyboard no longer pans the whole event form; the screen stays
anchored and the focused field scrolls into view (`adjustResize` +
`imePadding`)
## [1.2.0] — 2026-06-11
### Added
- Create events (milestone 2, slice 2):
- A "+" FAB on the month, week, and day views opens a new full-screen event
form, prefilled with the visible day (today at the next full hour, or
09:00 on other days)
- The form covers title, all-day toggle, start/end with Material 3 date and
time pickers (moving the start drags the end along, preserving duration),
target calendar, location, and description
- The calendar picker offers only writable calendars and preselects the one
you last created an event in
- Validation on save ("ends before it starts", no writable calendar), with
the same contextual write-permission upgrade as delete
- All-day events are stored provider-correctly (UTC midnights, exclusive
end), timed events in the device time zone
### Changed
- The jump-to-today pill now stacks above the new "+" FAB instead of being
the only floating action
- `versionName`/`versionCode` bumped to 1.2.0 / 9
## [1.1.0] — 2026-06-11
### Added
- Write foundation (milestone 2, slice 1): Calendula can now **delete events**.
- Delete action on the event detail screen, with a confirmation dialog;
recurring events choose between "Only this event" (a cancelled exception,
so the rest of the series survives) and "All events in the series"
- `WRITE_CALENDAR` permission: onboarding asks for read+write in one system
dialog, but only read access is required — declining write keeps the app
fully usable read-only. Existing v1.0 installs are asked for the write
upgrade in place, on their first delete
- Read-only calendars (WebCal subscriptions, birthday calendars, …) are
detected via `CALENDAR_ACCESS_LEVEL` and show no edit/delete actions at all
### Changed
- Onboarding copy no longer claims "read-only"; it now says your data stays on
the device (still no internet permission, still zero telemetry)
- The placeholder Edit button on the detail screen (a no-op since v0.4) is
removed until editing ships in a later slice
- `versionName`/`versionCode` bumped to 1.1.0 / 8
## [1.0.0] — 2026-06-11
First public release. Calendula is a read-only, Material 3 Expressive calendar

135
README.md
View File

@@ -1,43 +1,120 @@
# Calendula
<div align="center">
A modern Material 3 Expressive calendar app for Android.
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/icon.png" width="112" alt="Calendula icon">
Calendula is named after the flower of the same name, whose name comes from
the Latin *kalendae* — the first day of the month — the same root as the
word "calendar". Calendula reads from Android's built-in `CalendarContract`,
so any calendar source synced to your device (CalDAV via DAVx5, Google,
local, WebCal subscriptions, ...) is shown.
<h1>Calendula</h1>
## Features (V1)
<p><strong>A modern Material 3 Expressive calendar for Android.</strong><br>
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
- Month, Week, and Day views
- Read-only event details (write support comes in V2)
- Multi-calendar visibility toggle
- Material You Dynamic Color (Android 12+)
- Light/Dark theme follows system
- German + English UI
<p>
<a href="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions"><img src="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a>
</p>
## Building
<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">
</p>
Requires Android SDK 36 and JDK 17. The Gradle wrapper is checked in, so no host Gradle install is needed:
</div>
Calendula is named after the flower whose name — like the word *calendar*
comes from the Latin *kalendae*, the first day of the month. It lives
entirely on top of Android's `CalendarContract`: any calendar synced to your
device (CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply
appears, and everything you create or edit syncs back the same way. No own
database, no sync stack reinvented.
## ✨ Features
**Calendar**
- Month, week, and day views with a one-tap view switcher
- Full event details — attendees and their responses, reminders, recurrence
(humanized), availability, visibility, foreign time zones
- Per-calendar visibility toggle, grouped by account
**Editing**
- Create, edit, and delete events — including recurring events with scoped
writes: *only this event*, *this and all following*, or *the whole series*
- Recurrence picker with one-tap presets and custom rules (interval, weekday
toggles, end conditions); rules it can't express are preserved verbatim
- Conflict-safe saves: if an event changed elsewhere while you were editing,
Calendula asks instead of silently overwriting
- Read-only calendars (WebCal, birthdays) are detected and respected
**Reminders**
- Event reminders delivered by Calendula itself as notifications —
essential when it's your only calendar app, since Android delegates
reminder delivery to calendar apps
- Tap a reminder to land on the event
**Design & privacy**
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
expressive motion and shapes, light/dark theme
- German and English UI, per-app language setting
- **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.
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*:
```
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425
```
<sub>Repo: `https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo` ·
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.
Alternatively, build from source — see below.
## 🛠 Building
Requires Android SDK 36+ and JDK 17. The Gradle wrapper is checked in:
```bash
# Build debug APK
./gradlew assembleDebug
# Run unit tests
./gradlew test
# Run lint
./gradlew lint
./gradlew assembleDebug # debug APK
./gradlew test # JVM unit tests
./gradlew lint # Android lint
```
If your default JDK is something other than 17, set `JAVA_HOME` explicitly:
If your default JDK is not 17, set `JAVA_HOME` explicitly.
```bash
JAVA_HOME=/path/to/jdk-17 ./gradlew assembleDebug
```
## 🏗 Architecture
## License
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).
## 📜 License
[MIT](LICENSE) — Jean-Luc Makiola, 2026

View File

@@ -23,8 +23,13 @@ android {
applicationId = "de.jeanlucmakiola.calendula"
minSdk = 29
targetSdk = 36
versionCode = 7
versionName = "1.0.0"
// 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 = 20600
versionName = "2.6.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -73,6 +78,15 @@ android {
}
}
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() }
@@ -108,6 +122,9 @@ dependencies {
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.core)

View File

@@ -3,6 +3,27 @@
xmlns:tools="http://schemas.android.com/tools">
<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" />
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
returns null and the calendar manager's per-account "manage" button can't
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
intent makes launchable apps visible so we can launch whichever app owns a
calendar account's authenticator. -->
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<application
android:name=".CalendulaApp"
@@ -11,19 +32,86 @@
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"
tools:targetApi="35">
<activity
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<!-- 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. -->
<receiver
android:name=".data.reminders.EventReminderReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.EVENT_REMINDER" />
<data
android:host="com.android.calendar"
android:scheme="content" />
</intent-filter>
</receiver>
<!-- 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>
<!-- 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,26 +1,45 @@
package de.jeanlucmakiola.calendula
import android.content.Context
import android.content.Intent
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
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.prefs.ThemeMode
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
import kotlinx.datetime.LocalDate
@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)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestedDetailKey = intent.detailKeyOrNull()
requestedNav = intent.navRequestOrNull()
setContent {
// One activity-scoped SettingsViewModel drives both the theme here
// and the Settings screen, so a theme change applies app-wide at once.
@@ -35,8 +54,88 @@ class MainActivity : ComponentActivity() {
darkTheme = darkTheme,
dynamicColor = settings.dynamicColor,
) {
RootScreen(modifier = Modifier.fillMaxSize())
RootScreen(
modifier = Modifier.fillMaxSize(),
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = { requestedDetailKey = null },
widgetNavRequest = requestedNav,
onWidgetNavConsumed = { requestedNav = null },
)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it }
}
private fun Intent.navRequestOrNull(): WidgetNavRequest? = 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))
getStringExtra(EXTRA_DATE_ISO) != null ->
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!)
else -> null
}
private fun Intent.detailKeyOrNull(): LongArray? {
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
if (eventId == -1L) return null
return longArrayOf(
eventId,
getLongExtra(EXTRA_BEGIN_MILLIS, 0L),
getLongExtra(EXTRA_END_MILLIS, 0L),
)
}
companion object {
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"
// 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
* notifications). The synthetic data URI keys the intent so
* PendingIntents for different occurrences never collapse into one.
*/
fun eventDetailIntent(
context: Context,
eventId: Long,
beginMillis: Long,
endMillis: Long,
): Intent = Intent(context, MainActivity::class.java).apply {
data = "calendula://event/$eventId/$beginMillis".toUri()
putExtra(EXTRA_EVENT_ID, eventId)
putExtra(EXTRA_BEGIN_MILLIS, beginMillis)
putExtra(EXTRA_END_MILLIS, endMillis)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/** Open the day view anchored on [date] (home-screen widgets). */
fun openDateIntent(context: Context, date: LocalDate): Intent =
Intent(context, MainActivity::class.java).apply {
data = "calendula://date/$date".toUri()
putExtra(EXTRA_DATE_ISO, date.toString())
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/** 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,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

@@ -2,18 +2,27 @@ package de.jeanlucmakiola.calendula.data.calendar
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.CalendarContract
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.Attendee
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.Reminder
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import kotlinx.datetime.toJavaLocalDate
import java.time.ZoneId
import java.time.ZoneOffset
import javax.inject.Inject
import javax.inject.Singleton
@@ -29,6 +38,96 @@ interface CalendarDataSource {
fun calendars(): List<CalendarSource>
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
fun eventDetail(eventId: Long): EventDetail?
/**
* 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>
/**
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
* returns its `Calendars._ID`. Inserted through the sync-adapter URI so the
* provider keeps the row (a plain insert is rejected for the LOCAL account).
*/
fun createLocalCalendar(displayName: String, color: Int, description: String?): Long
/** Update name, color and description of a local calendar the app owns. */
fun updateCalendar(id: Long, displayName: String, color: Int, description: String?)
/** 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`. [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,
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`. [allDayReminderTimeMinutes]: see [insertEvent].
*/
fun updateOccurrence(
eventId: Long,
beginMillis: Long,
form: EventForm,
allDayReminderTimeMinutes: Int,
): Long
/**
* Change a recurring event from the occurrence at [beginMillis] onwards
* by splitting the series: the existing RRULE ends just before the
* occurrence and a new event with [updated]'s values (and rule) starts
* there; returns the new event's `Events._ID`. From the first occurrence
* this is a plain series update. A carried-over COUNT restarts counting
* in the new series (we don't recompute the remaining occurrences).
*/
fun updateEventFromOccurrence(
eventId: Long,
beginMillis: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
): Long
/**
* Delete a recurring event from the occurrence at [beginMillis] onwards
* by ending the series RRULE just before it. Deleting from the first
* occurrence removes the whole event.
*/
fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long)
/** Delete the whole event (for recurring events: the entire series). */
fun deleteEvent(eventId: Long)
/**
* Cancel a single occurrence of a recurring event by inserting a
* cancelled exception at [beginMillis] (the occurrence's `Instances.BEGIN`).
*/
fun deleteOccurrence(eventId: Long, beginMillis: Long)
fun registerChangeListener(listener: () -> Unit)
fun unregisterChangeListener(listener: () -> Unit)
}
@@ -48,6 +147,76 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
/**
* Calendar-row writes must address the provider as a sync adapter and name
* the account in the URI; otherwise inserts/deletes for the LOCAL account
* are silently dropped or only soft-deleted.
*/
private fun localCalendarsUri(): Uri = CalendarContract.Calendars.CONTENT_URI.buildUpon()
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
.appendQueryParameter(
CalendarContract.Calendars.ACCOUNT_TYPE,
CalendarContract.ACCOUNT_TYPE_LOCAL,
)
.build()
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val values = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
put(CalendarContract.Calendars.OWNER_ACCOUNT, LOCAL_ACCOUNT_NAME)
// NAME is the sync-adapter id; DISPLAY_NAME is what the user sees.
put(CalendarContract.Calendars.NAME, name)
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
put(
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
CalendarContract.Calendars.CAL_ACCESS_OWNER,
)
put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
putDescription(description)
}
val uri = resolver.insert(localCalendarsUri(), values)
?: throw WriteFailedException("create local calendar '$name'")
return ContentUris.parseId(uri)
}
override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) {
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val values = ContentValues().apply {
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
put(CalendarContract.Calendars.NAME, name)
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
putDescription(description)
}
val rows = resolver.update(
ContentUris.withAppendedId(localCalendarsUri(), id),
values, null, null,
)
if (rows == 0) throw WriteFailedException("update calendar id=$id")
}
/** Store the description in CAL_SYNC1, or clear it when blank/absent. */
private fun ContentValues.putDescription(description: String?) {
val text = description?.trim().orEmpty()
if (text.isEmpty()) {
putNull(CalendarProjection.DESCRIPTION_COLUMN)
} else {
put(CalendarProjection.DESCRIPTION_COLUMN, text)
}
}
override fun deleteCalendar(id: Long) {
val deleted = resolver.delete(
ContentUris.withAppendedId(localCalendarsUri(), id),
null, null,
)
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
}
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
@@ -74,6 +243,340 @@ class AndroidCalendarDataSource @Inject constructor(
}
}
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()
}
/** 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" },
)
put(CalendarContract.Events.TITLE, form.title.trim())
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
// The provider's invariant: recurring rows carry RRULE+DURATION
// (and no DTEND), one-off rows carry DTEND.
if (form.rrule == null) {
put(CalendarContract.Events.DTEND, times.dtEndMillis)
} else {
put(CalendarContract.Events.RRULE, form.rrule)
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
}
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
form.location.trim().takeIf { it.isNotEmpty() }
?.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.
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")
}
}
return eventId
}
override fun updateEvent(
eventId: Long,
original: EventForm,
updated: EventForm,
allDayReminderTimeMinutes: Int,
) {
val values = buildEventUpdateValues(
original = original,
updated = updated,
seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis,
zone = ZoneId.systemDefault(),
)
if (values.isNotEmpty()) {
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(),
null, null,
)
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. The diff is on the
// form's semantic minutes; reconcile works in encoded provider minutes.
if (updated.reminders.toSet() != original.reminders.toSet()) {
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
}
}
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,
originalInstanceMillis = beginMillis,
zone = ZoneId.systemDefault(),
)
val uri = resolver.insert(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId),
values.toContentValues(),
) ?: throw WriteFailedException("modify occurrence event id=$eventId begin=$beginMillis")
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, encodedReminders(form, allDayReminderTimeMinutes))
return exceptionId
}
override fun updateEventFromOccurrence(
eventId: Long,
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, allDayReminderTimeMinutes)
return eventId
}
// Insert the new series first: if it fails, the original is untouched.
val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
truncateSeries(eventId, row, beginMillis)
return newEventId
}
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
val row = querySeriesRow(eventId)
// From the first occurrence on = the whole series; also the fallback
// when there is no RRULE to truncate.
if (row.rrule.isNullOrBlank() || beginMillis <= row.dtStartMillis) {
deleteEvent(eventId)
return
}
truncateSeries(eventId, row, beginMillis)
}
/**
* End [row]'s series just before the occurrence at [beginMillis]. The
* provider regenerates an event's cached instances only from the values
* carried by the update itself — an RRULE-only update leaves the old
* instances standing (observed on-device: the truncated occurrence kept
* showing) — so the entire time-related set travels together, with only
* the RRULE actually changing.
*/
private fun truncateSeries(eventId: Long, row: SeriesRow, beginMillis: Long) {
requireNotNull(row.rrule) { "truncateSeries needs a recurring row" }
val values = ContentValues().apply {
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
put(CalendarContract.Events.DURATION, row.duration)
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
put(CalendarContract.Events.ALL_DAY, row.allDay)
put(
CalendarContract.Events.RRULE,
rruleTruncatedAt(row.rrule, untilUtcMillis = row.truncationCutoff(beginMillis)),
)
}
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values,
null, null,
)
if (rows == 0) {
throw WriteFailedException("truncate series event id=$eventId begin=$beginMillis")
}
}
/** The series anchor: every time-related column of the Events row. */
private fun querySeriesRow(eventId: Long): SeriesRow = resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
arrayOf(
CalendarContract.Events.DTSTART,
CalendarContract.Events.RRULE,
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.DURATION,
CalendarContract.Events.ALL_DAY,
),
null, null, null,
)?.use { c ->
if (c.moveToFirst()) {
SeriesRow(
dtStartMillis = c.getLong(0),
rrule = c.getString(1),
timezone = c.getString(2),
duration = c.getString(3),
allDay = c.getInt(4),
)
} else {
null
}
} ?: throw WriteFailedException("read series row of event id=$eventId")
private data class SeriesRow(
val dtStartMillis: Long,
val rrule: String?,
val timezone: String?,
val duration: String?,
val allDay: Int,
) {
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
beginMillis = beginMillis,
zone = runCatching { ZoneId.of(timezone ?: "UTC") }.getOrDefault(ZoneOffset.UTC),
)
}
/**
* 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()
val existing = queryReminders(eventId).map { it.minutes }.toSet()
(existing - target).forEach { minutes ->
resolver.delete(
CalendarContract.Reminders.CONTENT_URI,
CalendarContract.Reminders.EVENT_ID + " = ? AND " +
CalendarContract.Reminders.MINUTES + " = ?",
arrayOf(eventId.toString(), minutes.toString()),
)
}
(target - existing).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")
}
}
}
private fun Map<String, Any?>.toContentValues(): ContentValues =
ContentValues().also { cv ->
forEach { (column, value) ->
when (value) {
null -> cv.putNull(column)
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")
}
}
}
override fun deleteEvent(eventId: Long) {
val deleted = resolver.delete(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
null, null,
)
if (deleted == 0) throw WriteFailedException("delete event id=$eventId")
}
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
// A cancelled exception row hides exactly this occurrence; the sync
// adapter turns it into an EXDATE/cancelled VEVENT upstream.
val values = ContentValues().apply {
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, beginMillis)
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
}
val uri = ContentUris.withAppendedId(
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
)
resolver.insert(uri, values)
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
}
override fun registerChangeListener(listener: () -> Unit) {
val obs = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) {
@@ -119,4 +622,14 @@ class AndroidCalendarDataSource @Inject constructor(
private inline fun <T : Any> Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List<T> = buildList {
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
}
private companion object {
const val TAG = "CalendarDataSource"
/**
* Shared account for every app-created local calendar, so they group
* together (by account) in the filter sheet and calendar manager.
*/
const val LOCAL_ACCOUNT_NAME = "Calendula"
}
}

View File

@@ -1,13 +1,28 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.CalendarSource
internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource(
id = getLong(CalendarProjection.IDX_ID),
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
?: Fallbacks.UNNAMED_CALENDAR,
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty(),
color = getInt(CalendarProjection.IDX_COLOR),
isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0,
)
internal fun ColumnReader.toCalendarSource(): CalendarSource {
val accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty()
val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
return CalendarSource(
id = getLong(CalendarProjection.IDX_ID),
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
?: Fallbacks.UNNAMED_CALENDAR,
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
accountType = accountType,
color = getInt(CalendarProjection.IDX_COLOR),
isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0,
canModifyContents = getInt(CalendarProjection.IDX_ACCESS_LEVEL) >=
CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR,
isLocal = isLocal,
// CAL_SYNC1 holds the sync token for synced rows, so only treat it as a
// user description on the local calendars the app owns.
description = if (isLocal) {
getString(CalendarProjection.IDX_DESCRIPTION)?.takeIf { it.isNotBlank() }
} else {
null
},
)
}

View File

@@ -1,7 +1,9 @@
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 kotlinx.coroutines.flow.Flow
import kotlin.time.Instant
@@ -10,7 +12,61 @@ interface CalendarRepository {
fun calendars(): Flow<List<CalendarSource>>
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
suspend fun eventDetail(eventId: Long): EventDetail
/**
* 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
/** Update name, color and description of a local calendar the app owns. */
suspend fun updateCalendar(id: Long, displayName: String, color: Int, description: String?)
/** Permanently delete a local calendar the app owns, with all its events. */
suspend fun deleteCalendar(id: Long)
/** Create a new event from a validated form; returns the new `Events._ID`. */
suspend fun createEvent(form: EventForm): Long
/**
* Update an event (recurring: the whole series) from a validated form.
* [original] is the prefilled form, used to write only what changed.
*/
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
/**
* Change a single occurrence of a recurring event (exception row with the
* form's values); returns the exception's `Events._ID`.
*/
suspend fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
/**
* Change a recurring event from [beginMillis] onwards (series split);
* returns the new event's `Events._ID`.
*/
suspend fun updateEventFromOccurrence(
eventId: Long,
beginMillis: Long,
original: EventForm,
updated: EventForm,
): Long
/** Delete the whole event (for recurring events: the entire series). */
suspend fun deleteEvent(eventId: Long)
/** Cancel a single occurrence of a recurring event at its `Instances.BEGIN` time. */
suspend fun deleteOccurrence(eventId: Long, beginMillis: Long)
/** Delete a recurring event from the occurrence at [beginMillis] onwards. */
suspend fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long)
}
class NoSuchEventException(eventId: Long) :
NoSuchElementException("No event with id=$eventId")
/** A ContentResolver write affected no rows or returned no URI. */
class WriteFailedException(operation: String) :
RuntimeException("Calendar write failed: $operation")

View File

@@ -2,13 +2,17 @@ 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 kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
@@ -26,9 +30,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,
@@ -68,6 +77,73 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
}
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
withContext(io) { dataSource.eventColorPalette(calendarId) }
override suspend fun createLocalCalendar(
displayName: String,
color: Int,
description: String?,
): Long = withContext(io) {
dataSource.createLocalCalendar(displayName, color, description)
}
override suspend fun updateCalendar(
id: Long,
displayName: String,
color: Int,
description: String?,
) = withContext(io) { dataSource.updateCalendar(id, displayName, color, description) }
override suspend fun deleteCalendar(id: Long) =
withContext(io) { dataSource.deleteCalendar(id) }
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
dataSource.insertEvent(form, allDayReminderTimeMinutes())
}
override suspend fun updateEvent(
eventId: Long,
original: EventForm,
updated: EventForm,
) = withContext(io) {
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
}
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
dataSource.deleteEvent(eventId)
}
override suspend fun updateOccurrence(
eventId: Long,
beginMillis: Long,
form: EventForm,
): Long = withContext(io) {
dataSource.updateOccurrence(eventId, beginMillis, form, allDayReminderTimeMinutes())
}
override suspend fun updateEventFromOccurrence(
eventId: Long,
beginMillis: Long,
original: EventForm,
updated: EventForm,
): Long = withContext(io) {
dataSource.updateEventFromOccurrence(
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
)
}
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
dataSource.deleteOccurrence(eventId, beginMillis)
}
override suspend fun deleteEventFromOccurrence(
eventId: Long,
beginMillis: Long,
) = withContext(io) {
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
}
}
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {

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"
@@ -42,16 +45,23 @@ internal fun ColumnReader.toEventDetailCore(
rawEnd
}
val rawTitle = getString(EventDetailProjection.IDX_TITLE)
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
// Kept raw (no untitled fallback): the detail screen substitutes its own
// 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,
@@ -59,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)) {
@@ -78,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.
@@ -86,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

@@ -0,0 +1,189 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
internal data class EventWriteTimes(
val dtStartMillis: Long,
val dtEndMillis: Long,
val timezone: String,
)
/**
* All-day events live at UTC midnights with an exclusive DTEND (the
* CalendarContract convention — a one-day event ends at the next midnight);
* timed events resolve their wall-clock values in [zone].
*/
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
EventWriteTimes(
dtStartMillis = start.date.toJavaLocalDate()
.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
dtEndMillis = end.date.toJavaLocalDate().plusDays(1)
.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
timezone = "UTC",
)
} else {
EventWriteTimes(
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
timezone = zone.id,
)
}
/**
* RFC 2445 duration for a recurring event's row (the provider requires
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
* events, seconds otherwise.
*/
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) {
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D"
} else {
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
}
/**
* Dirty-checked column values for updating an existing Events row: only what
* the user actually changed is written, so untouched fields can't stomp
* concurrent external edits. Keys are `CalendarContract.Events` columns; a
* null value means "set the column to NULL". An empty map means nothing on
* the row changed.
*
* Time fields travel together (the provider validates them as a unit):
* - unchanged times, all-day flag and rrule → no time columns at all;
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
* - recurring result → the *series* DTSTART moves by the same delta the user
* applied to the displayed occurrence ([seriesDtStartMillis] is the row's
* current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps
* past occurrences intact when someone edits a later occurrence's time.
*/
internal fun buildEventUpdateValues(
original: EventForm,
updated: EventForm,
seriesDtStartMillis: Long,
zone: ZoneId,
): Map<String, Any?> = buildMap {
if (updated.title.trim() != original.title.trim()) {
put(CalendarContract.Events.TITLE, updated.title.trim())
}
if (updated.location.trim() != original.location.trim()) {
put(CalendarContract.Events.EVENT_LOCATION, updated.location.trim().ifEmpty { null })
}
if (updated.description.trim() != original.description.trim()) {
put(CalendarContract.Events.DESCRIPTION, updated.description.trim().ifEmpty { null })
}
if (updated.availability != original.availability) {
put(CalendarContract.Events.AVAILABILITY, updated.availability.toProviderValue())
}
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 ||
updated.isAllDay != original.isAllDay ||
updated.rrule != original.rrule
if (!timesChanged) return@buildMap
val newTimes = updated.toWriteTimes(zone)
put(CalendarContract.Events.ALL_DAY, if (updated.isAllDay) 1 else 0)
put(CalendarContract.Events.EVENT_TIMEZONE, newTimes.timezone)
if (updated.rrule == null) {
put(CalendarContract.Events.DTSTART, newTimes.dtStartMillis)
put(CalendarContract.Events.DTEND, newTimes.dtEndMillis)
put(CalendarContract.Events.RRULE, null)
put(CalendarContract.Events.DURATION, null)
} else {
val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis
put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta)
put(CalendarContract.Events.DTEND, null)
put(CalendarContract.Events.RRULE, updated.rrule)
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
}
}
/**
* Column values for a modified-occurrence exception row ("edit only this
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
* provider clone the series row and apply these on top. Unlike the series
* update there is no dirty check — the exception is a fresh row, so every
* form-backed column is written (empty optionals as explicit NULLs, since the
* clone starts from the parent's values). An exception is a single event:
* DTEND, never RRULE/DURATION.
*/
internal fun buildOccurrenceExceptionValues(
form: EventForm,
originalInstanceMillis: Long,
zone: ZoneId,
): Map<String, Any?> = buildMap {
val times = form.toWriteTimes(zone)
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis)
put(CalendarContract.Events.TITLE, form.title.trim())
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
put(CalendarContract.Events.DTEND, times.dtEndMillis)
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
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,
)
}
/**
* UTC millis of the last second of the local day *before* the occurrence at
* [beginMillis] in [zone] — the cutoff for truncating a series with UNTIL.
* The provider's recurrence engine applies UNTIL coarsely (observed on a
* Pixel: an occurrence one second *after* UNTIL was still generated), so the
* series must end on the previous day, not one second before the occurrence.
* With no sub-daily frequencies that is semantically the same cut.
*/
internal fun previousLocalDayEndUtcMillis(beginMillis: Long, zone: ZoneId): Long =
Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate()
.atStartOfDay(zone).toInstant().toEpochMilli() - 1_000L
private const val MILLIS_PER_DAY = 86_400_000L
internal fun Availability.toProviderValue(): Int = when (this) {
Availability.Busy -> CalendarContract.Events.AVAILABILITY_BUSY
Availability.Free -> CalendarContract.Events.AVAILABILITY_FREE
Availability.Tentative -> CalendarContract.Events.AVAILABILITY_TENTATIVE
}
internal fun AccessLevel.toProviderValue(): Int = when (this) {
AccessLevel.Default -> CalendarContract.Events.ACCESS_DEFAULT
AccessLevel.Confidential -> CalendarContract.Events.ACCESS_CONFIDENTIAL
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
}

View File

@@ -10,14 +10,23 @@ internal object CalendarProjection {
CalendarContract.Calendars.ACCOUNT_TYPE,
CalendarContract.Calendars.CALENDAR_COLOR,
CalendarContract.Calendars.VISIBLE,
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
// CalendarContract has no description column; for the local calendars we
// own we stash one in CAL_SYNC1 (synced rows put their sync token here,
// so the mapper only reads it for local calendars).
DESCRIPTION_COLUMN,
)
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
const val IDX_ID = 0
const val IDX_DISPLAY_NAME = 1
const val IDX_ACCOUNT_NAME = 2
const val IDX_ACCOUNT_TYPE = 3
const val IDX_COLOR = 4
const val IDX_VISIBLE = 5
const val IDX_ACCESS_LEVEL = 6
const val IDX_DESCRIPTION = 7
}
internal object InstanceProjection {
@@ -65,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
@@ -84,6 +94,7 @@ 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
}
internal object AttendeeProjection {

View File

@@ -14,6 +14,8 @@ import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton
@@ -37,6 +39,12 @@ abstract class DataBindModule {
abstract fun bindCalendarRepository(
impl: CalendarRepositoryImpl,
): CalendarRepository
@Binds
@Singleton
abstract fun bindReminderAlertStore(
impl: AndroidReminderAlertStore,
): ReminderAlertStore
}
@Module

View File

@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -38,7 +39,20 @@ class CalendarPrefs @Inject constructor(
}
}
/**
* The calendar the user last created an event in; preselected in the
* event form. Null until the first event is created.
*/
val lastUsedCalendarId: Flow<Long?> = store.data.map { prefs ->
prefs[LAST_USED_CALENDAR_KEY]
}
suspend fun setLastUsedCalendarId(id: Long) {
store.edit { prefs -> prefs[LAST_USED_CALENDAR_KEY] = id }
}
companion object {
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
}
}

View File

@@ -4,7 +4,9 @@ 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.stringPreferencesKey
import de.jeanlucmakiola.calendula.domain.EventFormField
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.datetime.DayOfWeek
@@ -67,12 +69,256 @@ class SettingsPrefs @Inject constructor(
store.edit { it[WEEK_START_KEY] = pref.name }
}
/**
* Optional event-form fields shown by default (the rest hide behind
* "more fields"). Stored comma-joined by enum name: an absent key means
* the factory default, an empty string means "none". Unknown names are
* dropped defensively, like the other enum prefs.
*/
val defaultFormFields: Flow<Set<EventFormField>> = store.data.map { prefs ->
parseFormFields(prefs[FORM_FIELDS_KEY])
}
suspend fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
store.edit { prefs ->
val current = parseFormFields(prefs[FORM_FIELDS_KEY])
val updated = if (enabled) current + field else current - field
prefs[FORM_FIELDS_KEY] = updated.joinToString(",") { it.name }
}
}
/**
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
* for users whose only calendar app this is, reminders are essential; the
* onboarding step and Settings warn about duplicates from a second app.
*/
val remindersEnabled: Flow<Boolean> = store.data.map { prefs ->
prefs[REMINDERS_ENABLED_KEY] ?: true
}
suspend fun setRemindersEnabled(enabled: Boolean) {
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".
*/
val reminderOnboardingDone: Flow<Boolean> = store.data.map { prefs ->
prefs[REMINDER_ONBOARDING_KEY] ?: false
}
suspend fun setReminderOnboardingDone() {
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) }
}
/**
* 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)
}
}
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
null -> DEFAULT_FORM_FIELDS
else -> stored.split(',')
.mapNotNull { name -> EventFormField.entries.firstOrNull { it.name == name.trim() } }
.toSet()
}
companion object {
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 FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
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 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)
}
}
/** 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)
}
}
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

@@ -0,0 +1,57 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.provider.CalendarContract
import androidx.core.content.ContextCompat
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
/**
* Becomes the app that turns the calendar provider's reminder alarms into
* visible notifications (the Etar model — the provider broadcasts
* `EVENT_REMINDER` at reminder time but posts nothing itself).
*
* The broadcast's data URI only carries the alarm time, so it is ignored:
* we query every still-scheduled, due `CalendarAlerts` row ourselves, post
* them, and mark them fired. Posting happens before marking — a crash in
* between re-posts silently (same tag) rather than losing the reminder.
*/
@AndroidEntryPoint
class EventReminderReceiver : BroadcastReceiver() {
@Inject lateinit var alertStore: ReminderAlertStore
@Inject lateinit var notifier: ReminderNotifier
@Inject lateinit var settingsPrefs: SettingsPrefs
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
val readGranted = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
if (!readGranted || !notifier.canPost()) return
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
if (settingsPrefs.remindersEnabled.first()) {
val now = System.currentTimeMillis()
val due = alertStore.dueAlerts(now)
due.forEach(notifier::post)
alertStore.markFired(due.map { it.alertId }, now)
}
} finally {
pendingResult.finish()
}
}
}
}

View File

@@ -0,0 +1,112 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.ContentValues
import android.content.Context
import android.provider.CalendarContract
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* One due row of the provider's `CalendarAlerts` table (a join with Events).
* Stays in the data layer: alerts feed the notification path only and never
* reach a screen, so there is no domain model for them.
*/
data class ReminderAlert(
val alertId: Long,
val eventId: Long,
val beginMillis: Long,
val endMillis: Long,
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
val title: String,
val location: String?,
val isAllDay: Boolean,
)
/**
* Seam over the `CalendarAlerts` table so the receiver logic can be exercised
* without a ContentResolver. The provider creates these rows itself — only
* for `METHOD_ALERT` reminders (verified in AOSP `CalendarAlarmManager`), so
* email reminders never show up here.
*/
interface ReminderAlertStore {
/** Alerts that are due (`ALARM_TIME` has passed) and still unhandled. */
fun dueAlerts(nowMillis: Long): List<ReminderAlert>
/**
* Mark the given alerts handled (`STATE_FIRED`) so a later broadcast does
* not surface them again. Best effort: this write needs `WRITE_CALENDAR`,
* which the user may have declined — then re-broadcasts silently replace
* the already-posted notifications instead (same tag, alert-once).
*/
fun markFired(alertIds: List<Long>, nowMillis: Long)
}
@Singleton
class AndroidReminderAlertStore @Inject constructor(
@ApplicationContext private val context: Context,
) : ReminderAlertStore {
override fun dueAlerts(nowMillis: Long): List<ReminderAlert> = context.contentResolver.query(
CalendarContract.CalendarAlerts.CONTENT_URI,
PROJECTION,
CalendarContract.CalendarAlerts.STATE + " = ? AND " +
CalendarContract.CalendarAlerts.ALARM_TIME + " <= ?",
arrayOf(
CalendarContract.CalendarAlerts.STATE_SCHEDULED.toString(),
nowMillis.toString(),
),
CalendarContract.CalendarAlerts.BEGIN + " ASC",
)?.use { c ->
buildList {
while (c.moveToNext()) {
add(
ReminderAlert(
alertId = c.getLong(0),
eventId = c.getLong(1),
beginMillis = c.getLong(2),
endMillis = c.getLong(3),
title = c.getString(4).orEmpty(),
location = c.getString(5)?.takeIf { it.isNotBlank() },
isAllDay = c.getInt(6) == 1,
),
)
}
}
} ?: emptyList()
override fun markFired(alertIds: List<Long>, nowMillis: Long) {
if (alertIds.isEmpty()) return
val values = ContentValues().apply {
put(CalendarContract.CalendarAlerts.STATE, CalendarContract.CalendarAlerts.STATE_FIRED)
put(CalendarContract.CalendarAlerts.RECEIVED_TIME, nowMillis)
put(CalendarContract.CalendarAlerts.NOTIFY_TIME, nowMillis)
}
try {
context.contentResolver.update(
CalendarContract.CalendarAlerts.CONTENT_URI,
values,
CalendarContract.CalendarAlerts._ID +
" IN (" + alertIds.joinToString(",") + ")",
null,
)
} catch (e: SecurityException) {
Log.w(TAG, "Cannot mark alerts fired without WRITE_CALENDAR", e)
}
}
private companion object {
const val TAG = "ReminderAlertStore"
val PROJECTION = arrayOf(
CalendarContract.CalendarAlerts._ID,
CalendarContract.CalendarAlerts.EVENT_ID,
CalendarContract.CalendarAlerts.BEGIN,
CalendarContract.CalendarAlerts.END,
CalendarContract.CalendarAlerts.TITLE,
CalendarContract.CalendarAlerts.EVENT_LOCATION,
CalendarContract.CalendarAlerts.ALL_DAY,
)
}
}

View File

@@ -0,0 +1,101 @@
package de.jeanlucmakiola.calendula.data.reminders
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 dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import java.time.ZoneId
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
/**
* Posts one notification per due reminder alert on a dedicated channel.
* Tapping opens the event's detail screen; the tag is the alert id, so a
* re-broadcast of an alert we couldn't mark fired replaces its notification
* silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of
* duplicating it.
*/
@Singleton
class ReminderNotifier @Inject constructor(
@ApplicationContext private val context: Context,
) {
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
fun canPost(): Boolean {
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
}
fun post(alert: ReminderAlert) {
ensureChannel()
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
val time = reminderTimeText(
beginMillis = alert.beginMillis,
endMillis = alert.endMillis,
isAllDay = alert.isAllDay,
zone = ZoneId.systemDefault(),
locale = Locale.getDefault(),
)
val text = listOfNotNull(time, alert.location).joinToString(" · ")
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(text)
.setWhen(alert.beginMillis)
.setShowWhen(false)
.setCategory(NotificationCompat.CATEGORY_EVENT)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setContentIntent(detailIntent(alert))
.build()
try {
NotificationManagerCompat.from(context)
.notify(alert.alertId.toString(), NOTIFICATION_ID, notification)
} catch (e: SecurityException) {
// POST_NOTIFICATIONS was revoked between canPost() and here.
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
}
}
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
context,
/* requestCode = */ alert.alertId.toInt(),
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** Channel creation is idempotent; re-running refreshes the localized name. */
private fun ensureChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.reminder_channel_name),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = context.getString(R.string.reminder_channel_description)
}
NotificationManagerCompat.from(context).createNotificationChannel(channel)
}
private companion object {
const val TAG = "ReminderNotifier"
const val CHANNEL_ID = "reminders"
// One id, distinct tags: the tag (alert id) already keys the
// notification, so a fixed id keeps cancellation/replacement simple.
const val NOTIFICATION_ID = 1
}
}

View File

@@ -0,0 +1,56 @@
package de.jeanlucmakiola.calendula.data.reminders
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
/**
* The one line of time context in a reminder notification. Pure so it can be
* JVM-tested:
*
* - timed, same day: "09:30 10:00"
* - timed, crossing days: "11 Jun, 23:30 12 Jun, 00:30" (medium date + short time)
* - all-day, one day: "11 Jun 2026"
* - all-day, multi-day: "11 Jun 2026 12 Jun 2026"
*
* All-day instances store UTC midnights with an exclusive end, so they are
* read in UTC and the end day is the last *covered* day.
*/
fun reminderTimeText(
beginMillis: Long,
endMillis: Long,
isAllDay: Boolean,
zone: ZoneId,
locale: Locale,
): String {
if (isAllDay) {
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
// (atZone().toLocalDate() instead of LocalDate.ofInstant — API 34+)
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
.minusDays(1)
.coerceAtLeast(firstDay)
return if (lastDay == firstDay) {
dateFormat.format(firstDay)
} else {
dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay)
}
}
val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(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)
}
}
private const val RANGE = " "

View File

@@ -0,0 +1,163 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
/**
* User input for creating an event (and, from v1.3, editing one). Times are
* wall-clock values in the device zone; the data layer translates them to
* provider millis (all-day events normalise to UTC midnights there).
*/
data class EventForm(
val calendarId: Long?,
val title: String = "",
val isAllDay: Boolean = false,
val start: LocalDateTime,
val end: LocalDateTime,
val location: String = "",
val description: String = "",
/** Reminder lead times in minutes before the start, deduplicated. */
val reminders: List<Int> = emptyList(),
val availability: Availability = Availability.Busy,
val accessLevel: AccessLevel = AccessLevel.Default,
/**
* Bare RRULE value (`Events.RRULE` convention, no "RRULE:" prefix); null
* means a one-off event. May hold rules the simple picker can't express —
* 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,
)
/**
* The form's optional sections. Which ones show by default is a user setting;
* the rest unfold behind a "more fields" button.
*/
enum class EventFormField {
Location,
Description,
Reminders,
Recurrence,
Availability,
Visibility,
Color,
}
enum class EventFormProblem {
/** No target calendar — none picked and no writable calendar exists. */
NoCalendar,
EndBeforeStart,
/** The recurrence's UNTIL date lies before the event's first day. */
RecurrenceEndsBeforeStart,
}
/**
* Validation; an empty set means the form can be saved. A blank title is
* allowed (display falls back to "(No title)", matching the provider), and a
* zero-length timed event is allowed (spec §8: instant events exist).
*/
/**
* Prefill the edit form from a loaded event. [beginMillis]/[endMillis] are the
* tapped occurrence's own times (`Instances.BEGIN`/`END`), not the series
* start — the data layer later turns a time edit into a delta on the series.
*
* All-day provider times are UTC midnights with an exclusive end; the form
* shows the last covered day and keeps placeholder wall-clock times in case
* the user switches the event to timed.
*/
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
val (start, end) = if (instance.isAllDay) {
val startDate = Instant.fromEpochMilliseconds(beginMillis)
.toLocalDateTime(TimeZone.UTC).date
val endExclusive = Instant.fromEpochMilliseconds(endMillis)
.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 {
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone)
}
return EventForm(
calendarId = instance.calendarId,
title = instance.title,
isAllDay = instance.isAllDay,
start = start,
end = end,
location = instance.location.orEmpty(),
description = description.orEmpty(),
reminders = reminders.map { it.minutes }.distinct().sorted(),
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,
)
}
/**
* What the edit form saw when it loaded — compared against a fresh read at
* save time to detect external changes (sync, another device) that landed
* 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.
*/
data class EditSnapshot(
val form: EventForm,
/** The raw Events-row times (for recurring events: the series anchor). */
val rowStart: Instant,
val rowEnd: Instant,
)
fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZone): EditSnapshot =
EditSnapshot(
form = toEditForm(beginMillis, endMillis, zone),
rowStart = instance.start,
rowEnd = instance.end,
)
/**
* The optional sections that hold a value in [form] — when editing, these
* must be visible regardless of the user's default-fields setting, or the
* data they carry would be invisible (though still preserved).
*/
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (location.isNotBlank()) add(EventFormField.Location)
if (description.isNotBlank()) add(EventFormField.Description)
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
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)
}
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
if (calendarId == null) add(EventFormProblem.NoCalendar)
val endsTooEarly = if (isAllDay) end.date < start.date else end < start
if (endsTooEarly) add(EventFormProblem.EndBeforeStart)
// An UNTIL before the first day would make the provider generate zero
// occurrences — the event would silently vanish from every view.
val recurrenceEnd = rrule?.let(::parseSimpleRecurrence)?.end
if (recurrenceEnd is RecurrenceEnd.Until && recurrenceEnd.date < start.date) {
add(EventFormProblem.RecurrenceEndsBeforeStart)
}
}

View File

@@ -9,6 +9,23 @@ data class CalendarSource(
val accountType: String,
val color: Int,
val isVisibleInSystem: Boolean,
/**
* Whether events in this calendar can be created/edited/deleted
* (`Calendars.CALENDAR_ACCESS_LEVEL` >= contributor). False for WebCal
* subscriptions, birthday calendars and other read-only sources.
*/
val canModifyContents: Boolean = false,
/**
* A device-only calendar the app itself owns (`ACCOUNT_TYPE_LOCAL`): it has
* no sync backend, so the app can rename / recolor / delete it. Synced
* calendars (Google, DAVx5, …) are managed in their own source app instead.
*/
val isLocal: Boolean = false,
/**
* Free-text note for a local calendar (stored in `CAL_SYNC1`, which the app
* owns for its own calendars). Always null for synced calendars.
*/
val description: String? = null,
)
data class EventInstance(
@@ -41,8 +58,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?,
@@ -109,6 +143,16 @@ enum class AccessLevel {
Confidential,
}
/**
* How far a write to a recurring event reaches. Non-recurring events always
* use [AllEvents] (there is only one).
*/
enum class RecurringWriteScope {
ThisEvent,
ThisAndFollowing,
AllEvents,
}
enum class FailureReason {
PermissionRevoked,
NoCalendarsConfigured,

View File

@@ -0,0 +1,197 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.isoDayNumber
import kotlinx.datetime.number
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
/**
* The recurrence shapes the simple picker can express (v1.3): a frequency,
* an interval, weekly weekday picks, and an optional end. Anything beyond
* that (ordinal BYDAY like "2TH", BYMONTHDAY, EXDATE rules, …) stays a raw
* RRULE string the picker shows as "custom" and leaves untouched unless the
* user replaces it.
*/
data class SimpleRecurrence(
val freq: RecurrenceFreq,
val interval: Int = 1,
val end: RecurrenceEnd = RecurrenceEnd.Never,
/**
* Weekly only: the weekdays the rule fires on (RRULE BYDAY). Empty means
* no BYDAY part — the provider derives the day from DTSTART.
*/
val byDays: Set<DayOfWeek> = emptySet(),
)
enum class RecurrenceFreq {
Daily,
Weekly,
Monthly,
Yearly,
}
sealed interface RecurrenceEnd {
data object Never : RecurrenceEnd
/** Last day on which an occurrence may fall (inclusive). */
data class Until(val date: LocalDate) : RecurrenceEnd
/** Total number of occurrences, counting the first. */
data class Count(val times: Int) : RecurrenceEnd
}
/**
* Parse an RRULE into the picker's simple shape, or null when the rule uses
* parts the picker can't represent (so the UI preserves the original string).
* Accepts an optional leading "RRULE:" and an ignored WKST part. A datetime
* UNTIL is converted from UTC into [zone] before its date is taken, mirroring
* [toRRule].
*/
fun parseSimpleRecurrence(
rrule: String,
zone: TimeZone = TimeZone.currentSystemDefault(),
): SimpleRecurrence? {
val parts = rrule.removePrefix("RRULE:").split(';')
.filter { it.isNotBlank() }
.associate { token ->
val eq = token.indexOf('=')
if (eq <= 0) return null
token.substring(0, eq).uppercase() to token.substring(eq + 1)
}
if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) {
return null
}
val freq = when (parts["FREQ"]?.uppercase()) {
"DAILY" -> RecurrenceFreq.Daily
"WEEKLY" -> RecurrenceFreq.Weekly
"MONTHLY" -> RecurrenceFreq.Monthly
"YEARLY" -> RecurrenceFreq.Yearly
else -> return null
}
val interval = parts["INTERVAL"]?.let { it.toIntOrNull()?.takeIf { n -> n >= 1 } ?: return null } ?: 1
// BYDAY is simple only as plain weekday picks on a weekly rule; ordinal
// forms ("2TH" = second Thursday) and BYDAY on other frequencies are not.
val byDays = parts["BYDAY"]?.let { raw ->
if (freq != RecurrenceFreq.Weekly) return null
raw.split(',').map { token -> rruleDay(token.trim()) ?: return null }.toSet()
} ?: emptySet()
val until = parts["UNTIL"]
val count = parts["COUNT"]
if (until != null && count != null) return null
val end = when {
until != null -> RecurrenceEnd.Until(parseUntilDate(until, zone) ?: return null)
count != null -> RecurrenceEnd.Count(count.toIntOrNull()?.takeIf { it >= 1 } ?: return null)
else -> RecurrenceEnd.Never
}
return SimpleRecurrence(freq, interval, end, byDays)
}
/**
* Render as a provider-ready RRULE value (no "RRULE:" prefix —
* `CalendarContract.Events.RRULE` stores the bare value). UNTIL is written as
* the end of the chosen day *in [zone]*, expressed in UTC: the recurrence
* engine has been observed applying UNTIL coarsely after converting it into
* the event's timezone, so a plain `T235959Z` can leak one extra day for
* zones ahead of UTC.
*/
fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()): String = buildString {
append("FREQ=")
append(
when (freq) {
RecurrenceFreq.Daily -> "DAILY"
RecurrenceFreq.Weekly -> "WEEKLY"
RecurrenceFreq.Monthly -> "MONTHLY"
RecurrenceFreq.Yearly -> "YEARLY"
},
)
if (interval > 1) append(";INTERVAL=$interval")
if (freq == RecurrenceFreq.Weekly && byDays.isNotEmpty()) {
append(";BYDAY=")
append(
byDays.sortedBy { it.isoDayNumber }
.joinToString(",") { RRULE_DAY_CODES.getValue(it) },
)
}
when (val e = end) {
RecurrenceEnd.Never -> Unit
is RecurrenceEnd.Until -> {
val utc = LocalDateTime(e.date, LocalTime(23, 59, 59))
.toInstant(zone)
.toLocalDateTime(TimeZone.UTC)
append(
";UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
utc.year, utc.month.number, utc.day,
utc.hour, utc.minute, utc.second,
),
)
}
is RecurrenceEnd.Count -> append(";COUNT=${e.times}")
}
}
private val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
DayOfWeek.MONDAY to "MO",
DayOfWeek.TUESDAY to "TU",
DayOfWeek.WEDNESDAY to "WE",
DayOfWeek.THURSDAY to "TH",
DayOfWeek.FRIDAY to "FR",
DayOfWeek.SATURDAY to "SA",
DayOfWeek.SUNDAY to "SU",
)
/** Exact two-letter BYDAY token → weekday; ordinal forms ("2TH") return null. */
private fun rruleDay(token: String): DayOfWeek? =
RRULE_DAY_CODES.entries.firstOrNull { it.value == token.uppercase() }?.key
/**
* End an arbitrary RRULE (simple or not) at [untilUtcMillis]: any existing
* UNTIL/COUNT is dropped, every other part (BYDAY, INTERVAL, …) survives.
* Used for "delete this and all following occurrences" — the caller passes a
* moment just before the first occurrence to remove.
*/
fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
val kept = rrule.removePrefix("RRULE:").split(';')
.filter { it.isNotBlank() }
.filterNot { part ->
val key = part.substringBefore('=').trim().uppercase()
key == "UNTIL" || key == "COUNT"
}
val until = Instant.fromEpochMilliseconds(untilUtcMillis).toLocalDateTime(TimeZone.UTC)
val untilPart = "UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
until.year, until.month.number, until.day,
until.hour, until.minute, until.second,
)
return (kept + untilPart).joinToString(";")
}
/**
* Date of an RRULE UNTIL value ("20260801" or "20260801T215959Z"). Datetime
* forms are UTC (RFC 5545); the date is taken after converting into [zone] so
* a [toRRule]-rendered value round-trips to the day the user picked.
*/
private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? = runCatching {
val date = LocalDate(
raw.substring(0, 4).toInt(),
raw.substring(4, 6).toInt(),
raw.substring(6, 8).toInt(),
)
if (raw.length >= 15 && raw[8] == 'T') {
val time = LocalTime(
raw.substring(9, 11).toInt(),
raw.substring(11, 13).toInt(),
raw.substring(13, 15).toInt(),
)
LocalDateTime(date, time).toInstant(TimeZone.UTC).toLocalDateTime(zone).date
} else {
date
}
}.getOrNull()

View File

@@ -8,6 +8,7 @@ import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
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
@@ -15,22 +16,39 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
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.CalendarView
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
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.month.MonthScreen
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
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
* pill in their top bars writes back here via [onSelectView].
*
* [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.
*/
@Composable
fun CalendarHost(modifier: Modifier = Modifier) {
fun CalendarHost(
modifier: Modifier = Modifier,
requestedDetailKey: LongArray? = null,
onDetailKeyConsumed: () -> Unit = {},
widgetNavRequest: WidgetNavRequest? = null,
onWidgetNavConsumed: () -> Unit = {},
) {
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
val onSelectView: (CalendarView) -> Unit = { view = it }
@@ -60,12 +78,71 @@ fun CalendarHost(modifier: Modifier = Modifier) {
detailKey = key
}
// A tapped reminder notification asks for a specific occurrence.
LaunchedEffect(requestedDetailKey) {
if (requestedDetailKey != null) {
heldKey = requestedDetailKey
detailKey = requestedDetailKey
onDetailKeyConsumed()
}
}
// Settings (M4) is hoisted here so it overlays whichever calendar view is
// active and survives view switches. (The calendar filter now lives inline
// in the navigation drawer, so no overlay state is needed for it.)
var showSettings by rememberSaveable { mutableStateOf(false) }
val onOpenSettings = { showSettings = true }
// Calendar manager (reached from Settings) — its own overlay so it slides
// over Settings and survives view switches.
var showCalendars by rememberSaveable { mutableStateOf(false) }
// Event form (v1.2 create) — same held-key pattern as the detail screen:
// [heldCreateIso] keeps the prefill date alive through the slide-out.
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
// when the form is opened from a day/week grid tap; null from the FAB.
var createDateIso by rememberSaveable { mutableStateOf<String?>(null) }
var heldCreateIso by remember { mutableStateOf<String?>(null) }
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes ->
heldCreateIso = date.toString()
createDateIso = date.toString()
heldCreateMinutes = startMinutes
createStartMinutes = startMinutes
}
// Edit form (v1.3) — reuses the detail screen's occurrence key; for
// recurring events the form itself asks for the write scope at save
// time. A saved edit closes the detail screen too: the occurrence the
// user tapped may not exist anymore (time moved, recurrence changed), so
// falling back to the auto-refreshing calendar is the only honest
// destination.
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
// A home-screen widget launch asks to open a date (→ day view) or start a
// create. Handled once and cleared, mirroring [requestedDetailKey].
LaunchedEffect(widgetNavRequest) {
when (val req = widgetNavRequest) {
is WidgetNavRequest.OpenDate -> {
pendingDayIso = req.dateIso
view = CalendarView.Day
onWidgetNavConsumed()
}
is WidgetNavRequest.Create -> {
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()
Box(modifier = modifier.fillMaxSize()) {
@@ -75,12 +152,14 @@ fun CalendarHost(modifier: Modifier = Modifier) {
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
)
CalendarView.Day -> DayScreen(
selectedView = view,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso,
)
CalendarView.Month -> MonthScreen(
@@ -88,6 +167,14 @@ fun CalendarHost(modifier: Modifier = Modifier) {
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
)
CalendarView.Agenda -> AgendaScreen(
selectedView = view,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
)
}
@@ -104,6 +191,45 @@ fun CalendarHost(modifier: Modifier = Modifier) {
beginMillis = key[1],
endMillis = key[2],
onBack = { detailKey = null },
onEdit = {
heldEditKey = key
editKey = key
},
)
}
}
// Event form (v1.2) — full-screen destination, slides over the calendar.
AnimatedVisibility(
visible = createDateIso != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
(createDateIso ?: heldCreateIso)?.let { iso ->
EventEditScreen(
initialDateIso = iso,
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
onClose = { createDateIso = null },
onSaved = { createDateIso = null },
)
}
}
// Edit form (v1.3) — slides over the detail screen.
AnimatedVisibility(
visible = editKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
(editKey ?: heldEditKey)?.let { key ->
EventEditScreen(
initialDateIso = null,
editKey = key,
onClose = { editKey = null },
onSaved = {
editKey = null
detailKey = null
},
)
}
}
@@ -114,7 +240,19 @@ fun CalendarHost(modifier: Modifier = Modifier) {
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
SettingsScreen(onBack = { showSettings = false })
SettingsScreen(
onBack = { showSettings = false },
onManageCalendars = { showCalendars = true },
)
}
// Calendar manager — slides over Settings.
AnimatedVisibility(
visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
CalendarsScreen(onBack = { showCalendars = false })
}
}
}

View File

@@ -10,14 +10,24 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
@Composable
fun RootScreen(modifier: Modifier = Modifier) {
fun RootScreen(
modifier: Modifier = Modifier,
requestedDetailKey: LongArray? = null,
onDetailKeyConsumed: () -> Unit = {},
widgetNavRequest: WidgetNavRequest? = null,
onWidgetNavConsumed: () -> Unit = {},
) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
@@ -40,7 +50,25 @@ fun RootScreen(modifier: Modifier = Modifier) {
}
if (hasPermission) {
CalendarHost(modifier = modifier)
// 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,
widgetNavRequest = widgetNavRequest,
onWidgetNavConsumed = onWidgetNavConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,
modifier = modifier,
)
null -> {}
}
} else {
PermissionScreen(
onGranted = { hasPermission = true },

View File

@@ -0,0 +1,15 @@
package de.jeanlucmakiola.calendula.ui
/**
* 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] (event taps reuse the existing reminder detail-key channel, so
* they are not modelled here).
*/
sealed interface WidgetNavRequest {
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date). */
data class OpenDate(val dateIso: String) : WidgetNavRequest
/** Open the create-event form prefilled for [dateIso] (today when null). */
data class Create(val dateIso: String?) : WidgetNavRequest
}

View File

@@ -0,0 +1,344 @@
package de.jeanlucmakiola.calendula.ui.agenda
import androidx.compose.foundation.ExperimentalFoundationApi
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.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.filled.EventAvailable
import androidx.compose.material.icons.filled.Menu
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.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.domain.EventInstance
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.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 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,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
viewModel: AgendaViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val isOnToday = when (val s = state) {
is AgendaUiState.Success -> s.anchor == s.today
else -> true
}
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() } },
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = !isOnToday,
todayText = stringResource(R.string.agenda_today_action),
onToday = viewModel::goToToday,
onCreate = { onCreateEvent(anchor, null) },
)
},
) { innerPadding ->
AgendaContent(
state = state,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
}
}
@Composable
private fun AgendaContent(
state: AgendaUiState,
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 ->
if (state.days.isEmpty()) {
AgendaEmpty(modifier)
} else {
AgendaList(state = state, onEventClick = onEventClick, modifier = modifier)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun AgendaList(
state: AgendaUiState.Success,
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),
) {
state.days.forEach { day ->
stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = state.today)
}
itemsIndexed(
items = day.events,
key = { _, event -> event.instanceId },
) { index, event ->
AgendaEventRow(
event = event,
position = positionOf(index, day.events.size),
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,
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow(
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.EventAvailable,
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,
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.agenda_empty_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AgendaTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> 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 = {
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 {
"${formatTime(event.start)} ${formatTime(event.end)}"
}
val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time
}
private fun formatTime(instant: Instant): String {
val t = instant.toLocalDateTime(zone).time
return "%02d:%02d".format(t.hour, t.minute)
}
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,57 @@
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>,
) : AgendaUiState
}

View File

@@ -0,0 +1,96 @@
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.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.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
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
/** How far ahead the agenda loads events from its anchor day. */
internal const val AGENDA_WINDOW_DAYS = 60
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class AgendaViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
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
val state: StateFlow<AgendaUiState> = _anchor
.flatMapLatest { anchor ->
val range = agendaRange(anchor, AGENDA_WINDOW_DAYS, zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(anchor, 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
}
private fun buildState(
anchor: LocalDate,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): AgendaUiState {
if (calendars.isEmpty()) {
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val days = groupAgendaDays(anchor, instances, zone)
return AgendaUiState.Success(anchor = anchor, today = todayDate, days = days)
}
}
/** 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

@@ -0,0 +1,500 @@
package de.jeanlucmakiola.calendula.ui.calendars
import android.accounts.AccountManager
import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.BackHandler
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.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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.filled.Add
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
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.Palette
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
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.Text
import androidx.compose.material3.TextButton
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
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.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
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
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
/**
* Calendar manager (reached from Settings). Lists the app's own device-only
* calendars with create / rename / recolor / delete (via a full-screen editor),
* and lists synced calendars read-only with a per-account "manage in the source
* app" deep-link — the app never touches a synced calendar's server. A
* full-screen destination; [onBack] pops it.
*/
@Composable
fun CalendarsScreen(
onBack: () -> Unit,
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val error by viewModel.error.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
// a fresh open while still surviving configuration changes within one open.
var editorId by rememberSaveable { mutableStateOf<Long?>(null) }
var editorSession by rememberSaveable { mutableStateOf(0) }
if (editorId != null) {
val editing = calendars.firstOrNull { it.id == editorId }
CalendarEditor(
sessionKey = editorSession,
isNew = editorId == NEW_CALENDAR_ID,
initialName = editing?.displayName.orEmpty(),
initialColor = editing?.color ?: CALENDAR_COLOR_PALETTE.first(),
initialDescription = editing?.description.orEmpty(),
onSave = { name, color, description ->
val id = editorId
if (id == null || id == NEW_CALENDAR_ID) {
viewModel.createCalendar(name, color, description)
} else {
viewModel.updateCalendar(id, name, color, description)
}
editorId = null
},
onDelete = {
editorId?.takeIf { it != NEW_CALENDAR_ID }?.let(viewModel::deleteCalendar)
editorId = null
},
onClose = { editorId = null },
)
} else {
CalendarsList(
local = calendars.filter { it.isLocal },
synced = calendars.filterNot { it.isLocal },
error = error,
onConsumeError = viewModel::consumeError,
onBack = onBack,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id },
)
}
}
@Composable
private fun CalendarsList(
local: List<CalendarSource>,
synced: List<CalendarSource>,
error: Boolean,
onConsumeError: () -> Unit,
onBack: () -> Unit,
onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit,
) {
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val writeErrorText = stringResource(R.string.calendars_write_error)
LaunchedEffect(error) {
if (error) {
snackbarHostState.showSnackbar(writeErrorText)
onConsumeError()
}
}
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),
)
},
onClick = { onEdit(calendar) },
)
}
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.
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) },
)
}
}
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)) }
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CalendarEditor(
sessionKey: Int,
isNew: Boolean,
initialName: String,
initialColor: Int,
initialDescription: String,
onSave: (name: String, color: Int, description: String?) -> Unit,
onDelete: () -> Unit,
onClose: () -> Unit,
) {
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
var confirmDelete by remember { mutableStateOf(false) }
val dark = isSystemInDarkTheme()
BackHandler(onBack = onClose)
Scaffold(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
topBar = {
TopAppBar(
title = {
Text(
stringResource(
if (isNew) R.string.calendars_new_title
else R.string.calendars_edit_title,
),
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.event_edit_close),
)
}
},
actions = {
if (!isNew) {
IconButton(onClick = { confirmDelete = true }) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete),
tint = MaterialTheme.colorScheme.error,
)
}
}
// Filled save button, matching the event editor's top bar.
Button(
onClick = {
onSave(name.trim(), color, description.trim().ifEmpty { null })
},
enabled = name.isNotBlank(),
modifier = Modifier.padding(end = 12.dp),
) {
Text(stringResource(R.string.event_edit_save))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = pastelize(color, dark)) {
InlineTextField(
value = name,
onValueChange = { name = it },
placeholder = stringResource(R.string.calendars_name_label),
textStyle = MaterialTheme.typography.titleLarge,
capitalization = KeyboardCapitalization.Sentences,
)
}
EditorCard(
icon = Icons.Default.Palette,
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
iconAtTop = true,
) {
Text(
text = stringResource(R.string.calendars_color_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
ColorSwatchRow(
colors = CALENDAR_COLOR_PALETTE,
selected = color,
onSelect = { color = it },
dark = dark,
)
}
EditorCard(
icon = Icons.AutoMirrored.Filled.Notes,
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
iconAtTop = true,
) {
InlineTextField(
value = description,
onValueChange = { description = it },
placeholder = stringResource(R.string.calendars_description_hint),
singleLine = false,
minLines = 2,
capitalization = KeyboardCapitalization.Sentences,
)
}
}
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text(stringResource(R.string.calendars_delete_confirm_title)) },
text = {
Text(stringResource(R.string.calendars_delete_confirm_message, initialName))
},
confirmButton = {
TextButton(onClick = {
confirmDelete = false
onDelete()
}) {
Text(
stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = { confirmDelete = false }) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
}
/** Tonal field card matching the event editor's design (icon + content). */
@Composable
private fun EditorCard(
icon: ImageVector,
iconTint: Color,
iconAtTop: Boolean = false,
content: @Composable () -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = iconTint,
modifier = Modifier
.padding(top = if (iconAtTop) 2.dp else 0.dp)
.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) { content() }
}
}
}
@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,
) {
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))
}
}
}
/** Neutral circular chip with a "+" — the leading icon for add-actions. */
@Composable
private fun AddAvatar() {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
}
@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 HintText(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
)
}
/**
* Pick the app to open for managing a synced calendar's account. The account's
* own authenticator package (resolved from [AccountManager], no permission
* needed) handles any sync provider — DAVx5, ICSx5, Nextcloud, … — and a small
* curated map redirects the few cases where the authenticator isn't the app to
* open (Google's authenticator is Play Services, but users want the Calendar
* app). Falls back to the system account settings when nothing launchable is
* found, so the button always lands somewhere sensible.
*/
private fun sourceAppIntent(context: Context, accountType: String): Intent {
val pm = context.packageManager
val candidates = buildList {
AccountManager.get(context).authenticatorTypes
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
?.packageName
?.let { add(it) }
curatedSourcePackage(accountType)?.let { add(it) }
}
for (pkg in candidates) {
pm.getLaunchIntentForPackage(pkg)?.let { return it }
}
return Intent(Settings.ACTION_SYNC_SETTINGS)
}
/** Preferred app for account types whose authenticator isn't the app to open. */
private fun curatedSourcePackage(accountType: String): String? = when {
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
else -> null
}

View File

@@ -0,0 +1,71 @@
package de.jeanlucmakiola.calendula.ui.calendars
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.CalendarSource
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.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
import javax.inject.Inject
/**
* Backs the calendar manager: lists every calendar (the screen splits them into
* the app's own local calendars and read-only/synced ones) and creates,
* renames, recolors or deletes the local calendars the app owns. Write failures
* flip [error] so the screen can surface a one-shot message.
*/
@HiltViewModel
class CalendarsViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
val calendars: StateFlow<List<CalendarSource>> =
repository.calendars()
.catch { emit(emptyList()) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyList(),
)
private val _error = MutableStateFlow(false)
val error: StateFlow<Boolean> = _error.asStateFlow()
fun consumeError() { _error.value = false }
fun createCalendar(displayName: String, color: Int, description: String?) = write {
repository.createLocalCalendar(displayName, color, description)
}
fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) = write {
repository.updateCalendar(id, displayName, color, description)
}
fun deleteCalendar(id: Long) = write {
repository.deleteCalendar(id)
}
private inline fun write(crossinline block: suspend () -> Unit) {
viewModelScope.launch {
try {
block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_error.value = true
}
}
}
}

View File

@@ -1,6 +1,20 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
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
/**
* Soften a raw calendar color toward a pastel that fits the active theme.
@@ -15,3 +29,27 @@ fun pastelize(rawArgb: Int, dark: Boolean): Color {
hsv[2] = if (dark) 0.82f else 0.72f
return Color(android.graphics.Color.HSVToColor(hsv))
}
/**
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
* in the calendar's (pastelised) colour. Shared by the calendar manager and the
* visibility filter so they read identically.
*/
@Composable
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme()
Box(
modifier = modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.CalendarMonth,
contentDescription = null,
tint = pastelize(color, dark),
modifier = Modifier.size(22.dp),
)
}
}

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

@@ -1,80 +1,155 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.material.icons.filled.Today
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.NavigationDrawerItem
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
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
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.
*
* Visual language (kept deliberately small so sizes don't drift):
* - Drawer title — `titleLarge`
* - Section headers (e.g. "Calendars") — `titleSmall`, primary, text only
* - Nav items (Today / Settings) — Material `NavigationDrawerItem`
* (`labelLarge` label + a single 24dp leading icon)
* Uses the app's grouped-card design system (see [GroupedRow]): a branded
* header, the View switcher as a grouped card (the active view highlighted),
* 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.
*
* Hosts the per-calendar visibility filter (M3) inline — the calendar list with
* its checkboxes lives here rather than in a separate sheet — plus the "today"
* jump and a Settings entry (M4). 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(
onToday: () -> Unit,
currentView: CalendarView,
currentDate: LocalDate,
onSelectView: (CalendarView) -> Unit,
onJumpToDate: (LocalDate) -> Unit,
onSettings: () -> Unit,
) {
var showDatePicker by remember { mutableStateOf(false) }
ModalDrawerSheet {
Column(Modifier.fillMaxHeight()) {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp),
)
HorizontalDivider()
Spacer(Modifier.height(8.dp))
NavigationDrawerItem(
icon = { Icon(Icons.Filled.Today, contentDescription = null) },
label = { Text(stringResource(R.string.month_today_action)) },
selected = false,
onClick = onToday,
modifier = Modifier.padding(horizontal = 12.dp),
)
Spacer(Modifier.height(8.dp))
HorizontalDivider()
// The whole sidebar scrolls as one — header, views, the calendar filter
// and Settings all flow in a single scroll container.
Column(
Modifier
.fillMaxHeight()
.verticalScroll(rememberScrollState()),
) {
DrawerHeader()
DrawerSectionHeader(stringResource(R.string.view_section))
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
GroupedRow(
title = stringResource(view.labelRes),
position = positionOf(index, IMPLEMENTED_VIEWS.size),
selected = view == currentView,
minHeight = 56.dp,
leading = { Icon(view.icon, contentDescription = null) },
onClick = { onSelectView(view) },
)
}
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))
// Calendars (M3) — visibility checkboxes, scrollable, takes the slack
// between the top actions and the pinned Settings entry.
DrawerSectionHeader(stringResource(R.string.filter_title))
CalendarFilterList(modifier = Modifier.weight(1f))
CalendarFilterList()
HorizontalDivider()
Spacer(Modifier.height(8.dp))
NavigationDrawerItem(
icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
label = { Text(stringResource(R.string.month_action_settings)) },
selected = false,
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.month_action_settings),
position = Position.Alone,
minHeight = 56.dp,
leading = { Icon(Icons.Filled.Settings, contentDescription = null) },
onClick = onSettings,
modifier = Modifier.padding(horizontal = 12.dp),
)
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. */
@Composable
private fun DrawerHeader() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 28.dp, top = 24.dp, bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(14.dp))
.background(colorResource(R.color.ic_launcher_background)),
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier.requiredSize(66.dp),
)
}
Spacer(Modifier.width(16.dp))
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
)
}
}
/** Top-level grouping label in the drawer. Text only, so it never reads as a

View File

@@ -0,0 +1,58 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/**
* The FAB stack shared by the three calendar views: a persistent "+" to
* create an event, with the jump-to-today pill appearing above it whenever
* the view isn't anchored on today.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun CalendarFabColumn(
todayVisible: Boolean,
todayText: String,
onToday: () -> Unit,
onCreate: () -> Unit,
) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
AnimatedVisibility(
visible = todayVisible,
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
) {
ExtendedFloatingActionButton(
onClick = onToday,
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
text = { Text(todayText) },
)
}
FloatingActionButton(onClick = onCreate) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.event_edit_new_title),
)
}
}
}

View File

@@ -1,21 +1,47 @@
package de.jeanlucmakiola.calendula.ui.common
/**
* The top-level calendar views the user can switch between (spec M1).
* Day is declared but not yet implemented (v0.5) — see [IMPLEMENTED_VIEWS].
*/
import androidx.annotation.StringRes
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). */
enum class CalendarView {
Month,
Week,
Day,
Agenda,
}
/** Switcher label, shared by the top-bar pill and the drawer's View section. */
@get:StringRes
val CalendarView.labelRes: Int
get() = when (this) {
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. */
val CalendarView.icon: ImageVector
get() = when (this) {
CalendarView.Month -> Icons.Filled.CalendarViewMonth
CalendarView.Week -> Icons.Filled.CalendarViewWeek
CalendarView.Day -> Icons.Filled.CalendarViewDay
CalendarView.Agenda -> Icons.Filled.ViewAgenda
}
/**
* Views that actually have a screen today. The view-switcher pill cycles
* 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 {

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,205 @@
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
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Column
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
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/**
* Position of a row within a grouped list, after the Android-15 settings
* pattern: a run of rows shares one rounded container, with full corners at the
* group's outer edges and small corners between, separated by small gaps.
*/
enum class Position { Top, Middle, Bottom, Alone }
/** Maps an index within a group of [count] rows to its [Position]. */
fun positionOf(index: Int, count: Int): Position = when {
count <= 1 -> Position.Alone
index == 0 -> Position.Top
index == count - 1 -> Position.Bottom
else -> Position.Middle
}
/**
* The app's standard full-screen list scaffold: a collapsing [LargeTopAppBar]
* whose title shrinks into the bar (next to the back button) as the content
* scrolls. Content is a scrollable column that feeds the toolbar via nested
* scroll. Used by Settings and the calendar manager so they share one shell.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CollapsingScaffold(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
snackbarHost: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
BackHandler(onBack = onBack)
val scrollBehavior =
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
Scaffold(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.settings_back),
)
}
},
actions = actions,
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
},
snackbarHost = snackbarHost,
) { innerPadding ->
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,
)
}
}
/**
* 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).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupedRow(
title: String,
position: Position,
modifier: Modifier = Modifier,
summary: String? = null,
selected: Boolean = false,
minHeight: Dp = 72.dp,
leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
val shape = when (position) {
Position.Alone -> RoundedCornerShape(full)
Position.Top -> RoundedCornerShape(
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
)
Position.Middle -> RoundedCornerShape(small)
Position.Bottom -> RoundedCornerShape(
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
)
}
val gap = when (position) {
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
Position.Bottom, Position.Alone -> Modifier
}
val itemColors = if (selected) {
ListItemDefaults.colors(
containerColor = Color.Transparent,
headlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
} else {
ListItemDefaults.colors(containerColor = Color.Transparent)
}
val item: @Composable () -> Unit = {
ListItem(
headlineContent = { Text(title) },
supportingContent = summary?.let { text -> { Text(text) } },
leadingContent = leading,
trailingContent = trailing,
colors = itemColors,
modifier = Modifier.heightIn(min = minHeight),
)
}
val base = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.then(gap)
val containerColor = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
if (onClick != null) {
Surface(
onClick = onClick,
color = containerColor,
shape = shape,
interactionSource = interaction,
modifier = base,
) { item() }
} else {
Surface(color = containerColor, shape = shape, modifier = base) { item() }
}
}

View File

@@ -0,0 +1,74 @@
package de.jeanlucmakiola.calendula.ui.common
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.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
/**
* The app's borderless text input: no underline, no outline, just the tonal
* card behind it. This is the standard input across the app — we deliberately
* don't use Material's outlined/filled text fields, so anything that takes text
* (the event form, the calendar manager, dialogs) uses this inside a tonal
* [androidx.compose.material3.Surface].
*/
@Composable
fun InlineTextField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
singleLine: Boolean = true,
minLines: Int = 1,
keyboardType: KeyboardType = KeyboardType.Text,
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
) {
val resolvedStyle = textStyle.copy(
color = if (textStyle.color.isSpecified) {
textStyle.color
} else {
MaterialTheme.colorScheme.onSurface
},
)
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = resolvedStyle,
singleLine = singleLine,
minLines = minLines,
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
capitalization = capitalization,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box {
if (value.isEmpty()) {
// Clearly fainter than typed text, so a hint never reads as
// prefilled content.
Text(
text = placeholder,
style = resolvedStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
innerTextField()
}
},
modifier = modifier,
)
}

View File

@@ -0,0 +1,94 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
/**
* The app's standard pick in a selection dialog: a full-width tonal card,
* optionally with a leading icon and a supporting line; the selected option
* is highlighted. Stack with 8dp gaps inside an AlertDialog — this is the
* only sanctioned selection-modal style (no radio rows, no bare text lists).
*/
@Composable
fun OptionCard(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
icon: ImageVector? = null,
/** Icon tint override, e.g. a calendar colour; unspecified follows selection. */
iconTint: Color = Color.Unspecified,
supportingText: String? = null,
selected: Boolean = false,
/** Label colour override, e.g. primary for an emphasised "Custom" entry. */
labelColor: Color = Color.Unspecified,
) {
val contentColor = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurface
}
Surface(
onClick = onClick,
color = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
},
shape = RoundedCornerShape(12.dp),
modifier = modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = when {
iconTint.isSpecified -> iconTint
selected -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(12.dp))
}
Column {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = if (labelColor.isSpecified) labelColor else contentColor,
)
if (supportingText != null) {
Text(
text = supportingText,
style = MaterialTheme.typography.bodySmall,
color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
}

View File

@@ -0,0 +1,282 @@
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.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.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
/**
* 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,
) {
FullScreenPicker(title = title, onDismiss = onDismiss) {
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) {
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))
}
}
}
}
}
@Composable
private fun SelectedCheck() {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
@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,126 @@
package de.jeanlucmakiola.calendula.ui.common
import android.icu.text.ListFormatter
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import de.jeanlucmakiola.calendula.R
import java.time.DayOfWeek
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
/**
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
* Falls back to a generic label for rules we don't render in full (ordinal
* monthly/yearly BYDAY, etc.). Shared by the detail screen and the edit
* form's repeat card.
*/
@Composable
fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
val eq = token.indexOf('=')
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
}.toMap()
val freq = parts["FREQ"]?.uppercase()
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
val base = when (freq) {
"DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily)
else stringResource(R.string.recurrence_every_n_days, interval)
"WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly)
else stringResource(R.string.recurrence_every_n_weeks, interval)
"MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly)
else stringResource(R.string.recurrence_every_n_months, interval)
"YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly)
else stringResource(R.string.recurrence_every_n_years, interval)
else -> return AnnotatedString(stringResource(R.string.event_detail_recurring))
}
// Weekly + BYDAY → "<base> on <days>"; other BYDAY forms keep just the base.
// The day names + their joined block are tracked so only the names (not the
// commas/conjunction) can be italicised in the final string.
val byDay = parts["BYDAY"]
var dayNames: List<String>? = null
var joinedDays: String? = null
val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) {
val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) }
if (days.isNotEmpty()) {
val joined = ListFormatter.getInstance(locale).format(days)
dayNames = days
joinedDays = joined
stringResource(R.string.recurrence_on_days, base, joined)
} else {
base
}
} else {
base
}
// End bound: UNTIL (a date) takes precedence over COUNT (a number of times).
val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) }
val count = parts["COUNT"]?.toIntOrNull()
val full = when {
until != null -> stringResource(R.string.recurrence_with_until, main, until)
count != null -> stringResource(R.string.recurrence_with_count, main, count)
else -> main
}
return buildAnnotatedString {
append(full)
val names = dayNames
val joined = joinedDays
if (names != null && joined != null) {
// Italicise each day name within the joined block only — leaving the
// separators and conjunction ("und"/"and") in the regular style.
val regionStart = full.indexOf(joined)
if (regionStart >= 0) {
val regionEnd = regionStart + joined.length
var cursor = regionStart
for (name in names) {
val at = full.indexOf(name, cursor)
if (at in regionStart until regionEnd) {
addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length)
cursor = at + name.length
}
}
}
}
}
}
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
private fun rruleDayName(token: String, locale: Locale): String? {
val dow = when (token.takeLast(2).uppercase()) {
"MO" -> DayOfWeek.MONDAY
"TU" -> DayOfWeek.TUESDAY
"WE" -> DayOfWeek.WEDNESDAY
"TH" -> DayOfWeek.THURSDAY
"FR" -> DayOfWeek.FRIDAY
"SA" -> DayOfWeek.SATURDAY
"SU" -> DayOfWeek.SUNDAY
else -> return null
}
return dow.getDisplayName(JavaTextStyle.SHORT, locale)
}
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
private fun parseUntilDate(raw: String, locale: Locale): String? {
val digits = raw.takeWhile { it.isDigit() }
if (digits.length < 8) return null
return try {
val date = java.time.LocalDate.of(
digits.substring(0, 4).toInt(),
digits.substring(4, 6).toInt(),
digits.substring(6, 8).toInt(),
)
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
} catch (e: Exception) {
null
}
}

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

@@ -6,7 +6,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
/**
* Top-bar pill that shows the current view and cycles to the next one on tap
@@ -18,16 +17,11 @@ fun ViewSwitcherPill(
onCycle: () -> Unit,
modifier: Modifier = Modifier,
) {
val labelRes = when (current) {
CalendarView.Month -> R.string.view_month
CalendarView.Week -> R.string.view_week
CalendarView.Day -> R.string.view_day
}
FilledTonalButton(
onClick = onCycle,
shape = MaterialTheme.shapes.large,
modifier = modifier,
) {
Text(stringResource(labelRes))
Text(stringResource(current.labelRes))
}
}

View File

@@ -1,14 +1,12 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -28,12 +26,10 @@ 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.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -72,6 +68,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
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.ViewSwitcherPill
@@ -108,6 +105,7 @@ fun DayScreen(
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
initialDateIso: String? = null,
viewModel: DayViewModel = hiltViewModel(),
@@ -144,7 +142,20 @@ fun DayScreen(
var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() }
val goPrev = { slideDir = -1; viewModel.goToPrev() }
val jumpToToday = { slideDir = 0; viewModel.goToToday() }
// Slide toward today: viewing the future → today comes in from the left
// (back), viewing the past → from the right (forward).
val jumpToToday = {
slideDir = when (val s = state) {
is DayUiState.Success -> if (s.today < s.date) -1 else 1
else -> 0
}
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,
@@ -152,7 +163,16 @@ fun DayScreen(
gesturesEnabled = drawerState.isOpen,
drawerContent = {
CalendarDrawer(
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
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() }
@@ -172,17 +192,12 @@ fun DayScreen(
)
},
floatingActionButton = {
AnimatedVisibility(
visible = !isOnToday,
enter = scaleIn(),
exit = scaleOut(),
) {
ExtendedFloatingActionButton(
onClick = jumpToToday,
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
text = { Text(stringResource(R.string.day_today_action)) },
)
}
CalendarFabColumn(
todayVisible = !isOnToday,
todayText = stringResource(R.string.day_today_action),
onToday = jumpToToday,
onCreate = { onCreateEvent(date, null) },
)
},
) { innerPadding ->
DayContent(
@@ -193,6 +208,7 @@ fun DayScreen(
onSwipePrev = goPrev,
onRetry = jumpToToday,
onEventClick = onEventClick,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
@@ -210,6 +226,7 @@ private fun DayContent(
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
@@ -278,6 +295,7 @@ private fun DayContent(
scrollState = scrollState,
allDayHeight = allDayHeight,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
)
}
}
@@ -290,6 +308,7 @@ private fun DaySuccess(
scrollState: ScrollState,
allDayHeight: Dp,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
// All-day strip collapses to nothing when the day has no all-day events,
@@ -305,7 +324,12 @@ private fun DaySuccess(
// Breathing room between the (colour-shifting) top section and the
// scrolling timeline below.
Spacer(Modifier.height(8.dp))
Timeline(state = state, scrollState = scrollState, onEventClick = onEventClick)
Timeline(
state = state,
scrollState = scrollState,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
)
}
}
@@ -423,6 +447,7 @@ private fun Timeline(
state: DayUiState.Success,
scrollState: ScrollState,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
) {
val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme()
@@ -470,7 +495,9 @@ private fun Timeline(
DayColumnCard(
blocks = state.timed,
dark = dark,
date = state.date,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
modifier = Modifier
.fillMaxWidth()
.height(totalHeight),
@@ -484,9 +511,12 @@ private fun Timeline(
private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
date: LocalDate,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
) {
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
Card(
// Plain rectangular column — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -496,7 +526,19 @@ private fun DayColumnCard(
),
modifier = modifier,
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
// 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.
.pointerInput(date) {
detectTapGestures { offset ->
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
onCreateAt(date, hour * 60)
}
},
) {
val colWidth = maxWidth
blocks.forEach { block ->
val laneWidth = colWidth / block.laneCount

View File

@@ -1,11 +1,14 @@
package de.jeanlucmakiola.calendula.ui.detail
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.icu.text.ListFormatter
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
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
@@ -38,32 +41,38 @@ 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.material3.AlertDialog
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.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.core.content.ContextCompat
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Dp
@@ -79,12 +88,15 @@ import de.jeanlucmakiola.calendula.domain.AttendeeType
import de.jeanlucmakiola.calendula.domain.Availability
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.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.OptionCard
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import kotlinx.datetime.TimeZone
import java.time.DayOfWeek
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@@ -94,10 +106,12 @@ import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* Read-only full-screen event detail (spec S4, realised as a navigation
* destination rather than a bottom sheet — MD3 list→detail pattern). Back
* gesture and the top-bar arrow both return to the calendar. The only action is
* tapping the location to open a maps intent.
* Full-screen event detail (spec S4, realised as a navigation destination
* rather than a bottom sheet — MD3 list→detail pattern). Back gesture and the
* top-bar arrow both return to the calendar. Events in writable calendars can
* be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared
* event form for this occurrence — for recurring events the form asks how
* far the change reaches when saving.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -106,16 +120,77 @@ fun EventDetailScreen(
beginMillis: Long,
endMillis: Long,
onBack: () -> Unit,
onEdit: () -> Unit,
viewModel: EventDetailViewModel = hiltViewModel(),
) {
LaunchedEffect(eventId, beginMillis, endMillis) {
viewModel.open(eventId, beginMillis, endMillis)
}
val state by viewModel.state.collectAsStateWithLifecycle()
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
BackHandler(onBack = onBack)
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
// 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) }
val writePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
if (pendingEdit) onEdit() else showDeleteDialog = true
}
pendingEdit = false
}
val hasWritePermission = {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
}
val onDeleteClick = {
if (hasWritePermission()) {
showDeleteDialog = true
} else {
pendingEdit = false
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
val onEditClick = {
if (hasWritePermission()) {
onEdit()
} else {
pendingEdit = true
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
val writeDeniedMessage = stringResource(R.string.event_delete_write_denied)
LaunchedEffect(deleteState) {
when (deleteState) {
DeleteUiState.Deleted -> {
viewModel.consumeDeleteResult()
onBack()
}
DeleteUiState.Failed -> {
viewModel.consumeDeleteResult()
snackbarHostState.showSnackbar(deleteFailedMessage)
}
DeleteUiState.NeedsPermission -> {
viewModel.consumeDeleteResult()
snackbarHostState.showSnackbar(writeDeniedMessage)
}
DeleteUiState.Idle, DeleteUiState.Deleting -> Unit
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {},
@@ -128,17 +203,28 @@ fun EventDetailScreen(
}
},
actions = {
IconButton(onClick = { /* TODO: edit event (V2) */ }) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = stringResource(R.string.event_detail_edit),
)
}
IconButton(onClick = { /* TODO: delete event (V2) */ }) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete),
)
// Only writable calendars get actions — WebCal subscriptions,
// birthday calendars etc. are read-only at the provider level.
val s = state
if (s is EventDetailUiState.Success && s.canModify) {
IconButton(
onClick = onEditClick,
enabled = deleteState != DeleteUiState.Deleting,
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = stringResource(R.string.event_detail_edit),
)
}
IconButton(
onClick = onDeleteClick,
enabled = deleteState != DeleteUiState.Deleting,
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete),
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
@@ -159,8 +245,84 @@ fun EventDetailScreen(
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
}
}
val loaded = state
if (showDeleteDialog && loaded is EventDetailUiState.Success) {
DeleteEventDialog(
isRecurring = !loaded.detail.rrule.isNullOrBlank(),
onConfirm = { scope ->
showDeleteDialog = false
viewModel.delete(scope)
},
onDismiss = { showDeleteDialog = false },
)
}
}
/**
* Delete confirmation. Recurring events choose between cancelling just the
* tapped occurrence (default), truncating the series from it onwards, and
* removing the whole series.
*/
@Composable
private fun DeleteEventDialog(
isRecurring: Boolean,
onConfirm: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
) {
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(
if (isRecurring) R.string.event_delete_recurring_title
else R.string.event_delete_title,
),
)
},
text = {
if (isRecurring) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { scope = RecurringWriteScope.ThisEvent },
selected = scope == RecurringWriteScope.ThisEvent,
)
OptionCard(
label = stringResource(R.string.event_delete_option_following),
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
selected = scope == RecurringWriteScope.ThisAndFollowing,
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { scope = RecurringWriteScope.AllEvents },
selected = scope == RecurringWriteScope.AllEvents,
)
}
} else {
Text(stringResource(R.string.event_delete_body))
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
) {
Text(
text = stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
@Composable
private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
val detail = state.detail
@@ -522,26 +684,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)"),
@@ -580,116 +723,6 @@ private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString = remem
}
}
/**
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
* Falls back to a generic label for rules we don't render in full (ordinal
* monthly/yearly BYDAY, etc.).
*/
@Composable
private fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
val eq = token.indexOf('=')
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
}.toMap()
val freq = parts["FREQ"]?.uppercase()
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
val base = when (freq) {
"DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily)
else stringResource(R.string.recurrence_every_n_days, interval)
"WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly)
else stringResource(R.string.recurrence_every_n_weeks, interval)
"MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly)
else stringResource(R.string.recurrence_every_n_months, interval)
"YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly)
else stringResource(R.string.recurrence_every_n_years, interval)
else -> return AnnotatedString(stringResource(R.string.event_detail_recurring))
}
// Weekly + BYDAY → "<base> on <days>"; other BYDAY forms keep just the base.
// The day names + their joined block are tracked so only the names (not the
// commas/conjunction) can be italicised in the final string.
val byDay = parts["BYDAY"]
var dayNames: List<String>? = null
var joinedDays: String? = null
val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) {
val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) }
if (days.isNotEmpty()) {
val joined = ListFormatter.getInstance(locale).format(days)
dayNames = days
joinedDays = joined
stringResource(R.string.recurrence_on_days, base, joined)
} else {
base
}
} else {
base
}
// End bound: UNTIL (a date) takes precedence over COUNT (a number of times).
val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) }
val count = parts["COUNT"]?.toIntOrNull()
val full = when {
until != null -> stringResource(R.string.recurrence_with_until, main, until)
count != null -> stringResource(R.string.recurrence_with_count, main, count)
else -> main
}
return buildAnnotatedString {
append(full)
val names = dayNames
val joined = joinedDays
if (names != null && joined != null) {
// Italicise each day name within the joined block only — leaving the
// separators and conjunction ("und"/"and") in the regular style.
val regionStart = full.indexOf(joined)
if (regionStart >= 0) {
val regionEnd = regionStart + joined.length
var cursor = regionStart
for (name in names) {
val at = full.indexOf(name, cursor)
if (at in regionStart until regionEnd) {
addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length)
cursor = at + name.length
}
}
}
}
}
}
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
private fun rruleDayName(token: String, locale: Locale): String? {
val dow = when (token.takeLast(2).uppercase()) {
"MO" -> DayOfWeek.MONDAY
"TU" -> DayOfWeek.TUESDAY
"WE" -> DayOfWeek.WEDNESDAY
"TH" -> DayOfWeek.THURSDAY
"FR" -> DayOfWeek.FRIDAY
"SA" -> DayOfWeek.SATURDAY
"SU" -> DayOfWeek.SUNDAY
else -> return null
}
return dow.getDisplayName(JavaTextStyle.SHORT, locale)
}
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
private fun parseUntilDate(raw: String, locale: Locale): String? {
val digits = raw.takeWhile { it.isDigit() }
if (digits.length < 8) return null
return try {
val date = java.time.LocalDate.of(
digits.substring(0, 4).toInt(),
digits.substring(4, 6).toInt(),
digits.substring(6, 8).toInt(),
)
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
} catch (e: Exception) {
null
}
}
/**
* Format an event's time into a primary line (date, or "All day") and an
* optional secondary line (time range). Multi-day timed events collapse into a

View File

@@ -4,7 +4,7 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.FailureReason
/**
* UI state for the event-detail bottom sheet (spec S4). Read-only in V1.
* UI state for the event-detail screen (spec S4).
*/
sealed interface EventDetailUiState {
data object Loading : EventDetailUiState
@@ -13,5 +13,20 @@ sealed interface EventDetailUiState {
val detail: EventDetail,
/** Display name of the owning calendar, null if it can't be resolved. */
val calendarName: String?,
/** Whether the owning calendar allows modifying events (shows edit/delete). */
val canModify: Boolean = false,
) : EventDetailUiState
}
/**
* One-shot state of a delete request, separate from the screen state so a
* failed delete leaves the loaded detail visible.
*/
sealed interface DeleteUiState {
data object Idle : DeleteUiState
data object Deleting : DeleteUiState
data object Deleted : DeleteUiState
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
data object NeedsPermission : DeleteUiState
data object Failed : DeleteUiState
}

View File

@@ -7,11 +7,13 @@ 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.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
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.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
@@ -19,6 +21,7 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Instant
import javax.inject.Inject
@@ -38,6 +41,9 @@ class EventDetailViewModel @Inject constructor(
// Bumped by retry() to re-run the load for the same target.
private val _reload = MutableStateFlow(0)
private val _deleteState = MutableStateFlow<DeleteUiState>(DeleteUiState.Idle)
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
val state: StateFlow<EventDetailUiState> =
combine(_target, _reload) { target, _ -> target }
.flatMapLatest { target ->
@@ -72,6 +78,41 @@ class EventDetailViewModel @Inject constructor(
_reload.value += 1
}
/**
* Delete the open event. [scope] is meaningful only for recurring events
* (one-off events always pass [RecurringWriteScope.AllEvents]). Result
* lands in [deleteState]; the screen consumes it via [consumeDeleteResult].
*/
fun delete(scope: RecurringWriteScope) {
val target = _target.value ?: return
if (_deleteState.value == DeleteUiState.Deleting) return
viewModelScope.launch {
_deleteState.value = DeleteUiState.Deleting
_deleteState.value = try {
when (scope) {
RecurringWriteScope.AllEvents ->
repository.deleteEvent(target.eventId)
RecurringWriteScope.ThisEvent ->
repository.deleteOccurrence(target.eventId, target.beginMillis)
RecurringWriteScope.ThisAndFollowing ->
repository.deleteEventFromOccurrence(target.eventId, target.beginMillis)
}
DeleteUiState.Deleted
} catch (e: CancellationException) {
throw e
} catch (e: SecurityException) {
DeleteUiState.NeedsPermission
} catch (e: Exception) {
DeleteUiState.Failed
}
}
}
/** Reset [deleteState] after the screen handled a terminal result. */
fun consumeDeleteResult() {
_deleteState.value = DeleteUiState.Idle
}
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
@@ -82,10 +123,13 @@ class EventDetailViewModel @Inject constructor(
end = Instant.fromEpochMilliseconds(target.endMillis),
),
)
val calendarName = repository.calendars().first()
val calendar = repository.calendars().first()
.firstOrNull { it.id == corrected.instance.calendarId }
?.displayName
EventDetailUiState.Success(corrected, calendarName)
EventDetailUiState.Success(
detail = corrected,
calendarName = calendar?.displayName,
canModify = calendar?.canModifyContents == true,
)
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
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
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
/**
* UI state for the event form (v1.2: create; v1.3 reuses it for edit). Null
* form means the screen hasn't been opened yet.
*/
data class EventEditUiState(
/** The form with its calendar id resolved (picked > last used > first writable). */
val form: EventForm,
/** Calendars that accept writes — the only valid targets. */
val calendars: List<CalendarSource>,
/** Validation problems; empty until a save was attempted. */
val problems: Set<EventFormProblem>,
val saveState: SaveUiState,
/** Optional sections currently rendered (settings defaults revealed). */
val visibleFields: Set<EventFormField> = emptySet(),
/**
* Optional sections behind "more fields". Sections the current mode can't
* offer at all (recurrence while editing a single occurrence) appear in
* neither list.
*/
val hiddenFields: List<EventFormField> = emptyList(),
/** True while editing an existing event (the calendar is then fixed). */
val isEditing: Boolean = false,
/**
* 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. */
sealed interface SaveUiState {
data object Idle : SaveUiState
/** A dirty recurring event waits for the user to pick the write scope. */
data object AwaitingScope : SaveUiState
/**
* The event changed externally (sync) while the form was open; the save
* is parked with its chosen [scope] until the user picks overwrite,
* discard, or cancel.
*/
data class AwaitingConflict(val scope: RecurringWriteScope) : SaveUiState
/** The event was deleted externally while the form was open. */
data object Gone : SaveUiState
data object Saving : SaveUiState
data object Saved : SaveUiState
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
data object NeedsPermission : SaveUiState
data object Failed : SaveUiState
}

View File

@@ -0,0 +1,464 @@
package de.jeanlucmakiola.calendula.ui.edit
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.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.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
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
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
import javax.inject.Inject
/**
* Holds the event form being composed. The form's calendar id resolves to
* (user pick > last used > first writable); the resolved value is what the UI
* shows and what gets saved.
*/
@HiltViewModel
class EventEditViewModel @Inject constructor(
private val repository: CalendarRepository,
private val prefs: CalendarPrefs,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val _form = MutableStateFlow<EventForm?>(null)
private val _saveState = MutableStateFlow<SaveUiState>(SaveUiState.Idle)
// Problems stay hidden until the first save attempt, so a half-filled
// form isn't already shouting errors.
private val _showProblems = MutableStateFlow(false)
// Fields added through the "more fields" picker; folds back on reset().
// openForEdit seeds it with the sections that already hold values.
private val _revealed = MutableStateFlow<Set<EventFormField>>(emptySet())
// 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()
/**
* The event being edited plus everything the form saw at load time.
* For recurring events the write scope is chosen at save time; the
* tapped occurrence's [beginMillis]/[endMillis] anchor occurrence-level
* writes and the conflict re-read. [zone] is pinned at load so a device
* timezone change mid-edit can't fake a conflict.
*/
private data class EditTarget(
val eventId: Long,
val snapshot: EditSnapshot,
val beginMillis: Long,
val endMillis: Long,
val zone: TimeZone,
) {
val original: EventForm get() = snapshot.form
}
private data class LocalInputs(
val form: EventForm?,
val saveState: SaveUiState,
val showProblems: Boolean,
val revealed: Set<EventFormField>,
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,
)
/** Writable calendars — the only valid event targets. */
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents } }
.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(
writableCalendars,
prefs.lastUsedCalendarId,
settingsPrefs.defaultFormFields,
settingsPrefs.allowColorOnUnsupportedCalendars,
::ExternalInputs,
).flowOn(io),
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 } }
?: external.writable.firstOrNull()?.id
val resolved = form.copy(calendarId = resolvedId)
val visibleFields = external.defaultFields + local.revealed
EventEditUiState(
form = resolved,
calendars = external.writable,
problems = if (local.showProblems) resolved.problems() else emptySet(),
saveState = local.saveState,
visibleFields = visibleFields,
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
isEditing = local.editTarget != null,
// 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(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
/**
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
* from midnight) anchors the start when the form is opened by tapping a slot
* in the day/week grid; without it the default is the next full hour (today)
* or 09:00 (any other day). No-op when a form is already open, so user input
* survives configuration changes; [reset] clears it when the screen closes.
*/
fun openNew(date: LocalDate, startMinutes: Int? = null) {
if (_form.value != null) return
val zone = TimeZone.currentSystemDefault()
val now = Clock.System.now()
val start = when {
startMinutes != null ->
LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60))
date == now.toLocalDateTime(zone).date -> {
// Today: the next full hour (may roll into tomorrow before midnight).
val hourMillis = 3_600_000L
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
}
else -> LocalDateTime(date, LocalTime(9, 0))
}
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
_form.value = EventForm(calendarId = null, start = start, end = end)
applyDefaultReminder()
}
/**
* 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
}
}
}
/**
* Load an existing event into the form. [beginMillis]/[endMillis] are the
* tapped occurrence's own times, like on the detail screen. No-op while a
* form is open, so user edits survive configuration changes.
*/
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
if (_form.value != null || _editTarget.value != null) return
viewModelScope.launch {
val detail = try {
repository.eventDetail(eventId)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_loadFailed.value = true
return@launch
}
val zone = TimeZone.currentSystemDefault()
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone)
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone)
// Sections holding data must show even when not in the defaults.
_revealed.value = snapshot.form.populatedFields()
_form.value = snapshot.form
}
}
/** Forget the open form; the next [openNew]/[openForEdit] starts clean. */
fun reset() {
_form.value = null
_saveState.value = SaveUiState.Idle
_showProblems.value = false
_revealed.value = emptySet()
_editTarget.value = null
_loadFailed.value = false
_remindersTouched.value = false
}
/** Unfold one optional field, picked in the "more fields" dialog. */
fun revealField(field: EventFormField) {
_revealed.value = _revealed.value + field
}
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) }
// 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) {
_remindersTouched.value = true
update { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
}
fun removeReminder(minutes: Int) {
_remindersTouched.value = true
update { it.copy(reminders = it.reminders - minutes) }
}
/** Moving the start drags the end along, preserving the duration. */
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
fun setEndDate(date: LocalDate) = update { it.copy(end = LocalDateTime(date, it.end.time)) }
fun setEndTime(time: LocalTime) = update { it.copy(end = LocalDateTime(it.end.date, time)) }
/**
* Validate and write. Saving a dirty recurring event pauses in
* [SaveUiState.AwaitingScope] until the screen answers via
* [saveWithScope]; everything else writes directly. Terminal results
* land in [saveState].
*/
fun save() {
val current = state.value ?: return
if (current.saveState == SaveUiState.Saving) return
val form = current.form
if (form.problems().isNotEmpty()) {
_showProblems.value = true
return
}
val target = _editTarget.value
if (target != null && form == target.original) {
// A pristine form saves as a no-op instead of a write.
_saveState.value = SaveUiState.Saved
return
}
if (target != null && target.original.rrule != null) {
_saveState.value = SaveUiState.AwaitingScope
return
}
performSave(form, RecurringWriteScope.AllEvents)
}
/** Finish a save parked in [SaveUiState.AwaitingScope]. */
fun saveWithScope(scope: RecurringWriteScope) {
val current = state.value ?: return
if (current.saveState != SaveUiState.AwaitingScope) return
performSave(current.form, scope)
}
/** Finish a save parked in [SaveUiState.AwaitingConflict], overwriting. */
fun saveOverwriting() {
val current = state.value ?: return
val parked = current.saveState as? SaveUiState.AwaitingConflict ?: return
performSave(current.form, parked.scope, ignoreConflict = true)
}
private fun performSave(
form: EventForm,
scope: RecurringWriteScope,
ignoreConflict: Boolean = false,
) {
val target = _editTarget.value
viewModelScope.launch {
_saveState.value = SaveUiState.Saving
// No locking (plan 03, decision 5): right before writing, re-read
// the event and compare against what the form loaded. An external
// change parks the save in a conflict dialog instead of silently
// clobbering the edited fields.
if (target != null && !ignoreConflict) {
val fresh = try {
repository.eventDetail(target.eventId)
.toEditSnapshot(target.beginMillis, target.endMillis, target.zone)
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
_saveState.value = SaveUiState.Gone
return@launch
} catch (e: Exception) {
// Can't verify — proceed; a real problem fails the write itself.
null
}
if (fresh != null && fresh != target.snapshot) {
_saveState.value = SaveUiState.AwaitingConflict(scope)
return@launch
}
}
_saveState.value = try {
if (target == null) {
repository.createEvent(form)
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
} else {
when (scope) {
RecurringWriteScope.ThisEvent ->
repository.updateOccurrence(target.eventId, target.beginMillis, form)
RecurringWriteScope.ThisAndFollowing ->
repository.updateEventFromOccurrence(
eventId = target.eventId,
beginMillis = target.beginMillis,
original = target.original,
updated = form,
)
RecurringWriteScope.AllEvents ->
repository.updateEvent(target.eventId, target.original, form)
}
}
SaveUiState.Saved
} catch (e: CancellationException) {
throw e
} catch (e: SecurityException) {
SaveUiState.NeedsPermission
} catch (e: Exception) {
SaveUiState.Failed
}
}
}
/** Reset [saveState] after the screen handled a terminal result. */
fun consumeSaveResult() {
_saveState.value = SaveUiState.Idle
}
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
val zone = TimeZone.currentSystemDefault()
val newStart = transform(form.start)
val duration = form.end.toInstant(zone) - form.start.toInstant(zone)
val newEnd = (newStart.toInstant(zone) + duration).toLocalDateTime(zone)
form.copy(start = newStart, end = newEnd)
}
private inline fun update(block: (EventForm) -> EventForm) {
_form.value = _form.value?.let(block)
}
}

View File

@@ -1,25 +1,17 @@
package de.jeanlucmakiola.calendula.ui.filter
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.Row
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@@ -28,7 +20,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.positionOf
/**
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
@@ -53,67 +47,44 @@ fun CalendarFilterList(
}
}
/**
* Renders as a plain (non-scrolling) [Column] so it flows inside the drawer's
* single scroll container — the whole sidebar scrolls as one. Calendar counts
* are small, so a lazy list isn't needed.
*/
@Composable
private fun FilterList(
groups: List<AccountGroup>,
onSetVisible: (Long, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val dark = isSystemInDarkTheme()
LazyColumn(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(vertical = 4.dp),
) {
Column(modifier = modifier.fillMaxWidth()) {
groups.forEach { group ->
item(key = "header-${group.account}") {
Text(
text = group.account,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
)
}
items(group.calendars, key = { it.id }) { cal ->
CalendarToggleRow(
row = cal,
dark = dark,
onCheckedChange = { onSetVisible(cal.id, it) },
Text(
text = group.account,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
)
group.calendars.forEachIndexed { index, cal ->
GroupedRow(
title = cal.displayName,
position = positionOf(index, group.calendars.size),
minHeight = 56.dp,
leading = { CalendarColorChip(cal.color) },
trailing = {
Checkbox(
checked = cal.visible,
onCheckedChange = { onSetVisible(cal.id, it) },
)
},
onClick = { onSetVisible(cal.id, !cal.visible) },
)
}
}
}
}
@Composable
private fun CalendarToggleRow(
row: CalendarRow,
dark: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 28.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.size(14.dp)
.background(pastelize(row.color, dark), CircleShape),
)
Text(
text = row.displayName,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Checkbox(
checked = row.visible,
onCheckedChange = onCheckedChange,
)
}
}
@Composable
private fun FilterLoading(modifier: Modifier = Modifier) {
Column(

View File

@@ -1,35 +1,30 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -48,7 +43,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
@@ -57,11 +54,13 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
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.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.ViewSwitcherPill
@@ -71,11 +70,12 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
@@ -86,6 +86,7 @@ fun MonthScreen(
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onOpenSettings: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
viewModel: MonthViewModel = hiltViewModel(),
) {
@@ -113,10 +114,21 @@ fun MonthScreen(
slideDir = -1
viewModel.goToPrev()
}
// Slide toward today: viewing the future → today comes in from the left
// (back), viewing the past → from the right (forward).
val jumpToToday = {
slideDir = 0
slideDir = when (val s = state) {
is MonthUiState.Success ->
if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1
else -> 0
}
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,
@@ -124,8 +136,14 @@ fun MonthScreen(
gesturesEnabled = drawerState.isOpen,
drawerContent = {
CalendarDrawer(
onToday = {
jumpToToday()
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 = {
@@ -147,17 +165,21 @@ fun MonthScreen(
)
},
floatingActionButton = {
AnimatedVisibility(
visible = !isOnCurrentMonth,
enter = scaleIn(),
exit = scaleOut(),
) {
ExtendedFloatingActionButton(
onClick = jumpToToday,
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
text = { Text(stringResource(R.string.month_today_action)) },
)
}
CalendarFabColumn(
todayVisible = !isOnCurrentMonth,
todayText = stringResource(R.string.month_today_action),
onToday = jumpToToday,
onCreate = {
// Anchor on today when its month is shown, else the 1st.
val today = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date
onCreateEvent(
if (isOnCurrentMonth) today
else LocalDate(month.year, month.month, 1),
null,
)
},
)
},
) { innerPadding ->
Column(
@@ -168,7 +190,6 @@ fun MonthScreen(
WeekdayHeader(weekStart = weekStart)
MonthContent(
state = state,
weekStart = weekStart,
slideDir = slideDir,
onSwipeNext = goNext,
onSwipePrev = goPrev,
@@ -183,7 +204,6 @@ fun MonthScreen(
@Composable
private fun MonthContent(
state: MonthUiState,
weekStart: DayOfWeek,
slideDir: Int,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
@@ -228,7 +248,6 @@ private fun MonthContent(
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is MonthUiState.Success -> MonthGrid(
state = s,
weekStart = weekStart,
onOpenDay = onOpenDay,
)
}
@@ -298,140 +317,279 @@ private fun WeekdayHeader(weekStart: DayOfWeek) {
}
}
private val EVENT_ROW_HEIGHT = 20.dp
private val DAY_NUMBER_HEIGHT = 22.dp
private val DAY_NUMBER_GAP = 4.dp
private val CELL_TOP_PADDING = 6.dp
private val CELL_GAP = 2.dp
private val CELL_SHAPE = RoundedCornerShape(12.dp)
private const val MAX_EVENT_ROWS = 3
@Composable
private fun MonthGrid(
state: MonthUiState.Success,
weekStart: DayOfWeek,
onOpenDay: (LocalDate) -> Unit,
) {
val firstOfMonth = LocalDate(state.month.year, state.month.month, 1)
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
// Show only the weeks the current month actually touches; leading/trailing
// days of neighbouring months are left blank rather than rendered.
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
val daysInMonth =
java.time.YearMonth.of(state.month.year, state.month.month.ordinal + 1).lengthOfMonth()
val weeks = (leadOffset + daysInMonth + 6) / 7
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
.padding(horizontal = 4.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
repeat(weeks) { row ->
Row(
state.weeks.forEach { week ->
MonthWeekRow(
week = week,
today = state.today,
month = state.month,
onOpenDay = onOpenDay,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
horizontalArrangement = Arrangement.spacedBy(4.dp),
)
}
}
}
/**
* One week of the grid. Bars (all-day / multi-day) are positioned absolutely so
* a multi-day event is one connected bar across the columns; single-day timed
* events sit beneath them as filled pills in their own cell. The cap is
* [MAX_EVENT_ROWS] rows of bars+pills, then a "+N" dot indicator per day.
* A transparent per-day layer on top turns a tap into "open that day".
*/
@Composable
private fun MonthWeekRow(
week: MonthWeek,
today: LocalDate,
month: YearMonth,
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
val dark = isSystemInDarkTheme()
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
BoxWithConstraints(modifier) {
val colW = maxWidth / 7
// Per-day background pills — same surfaceContainer rounded surface the
// week/day views use, so the three views share one visual language.
// Spanning bars draw on top of these, bridging cells, so they still read
// as one continuous event.
Row(Modifier.matchParentSize()) {
week.days.forEach { d ->
val inMonth = d.month == month.month && d.year == month.year
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.background(
color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer
else MaterialTheme.colorScheme.surfaceContainerLow,
shape = CELL_SHAPE,
),
)
}
}
Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) {
Row(Modifier.fillMaxWidth()) {
week.days.forEach { d ->
DayNumberCell(
date = d,
isToday = d == today,
inMonth = d.month == month.month && d.year == month.year,
modifier = Modifier.weight(1f),
)
}
}
// Breathing room between the day number (and today's circle) and the
// first event row.
Spacer(Modifier.height(DAY_NUMBER_GAP))
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clipToBounds(),
) {
repeat(7) { col ->
val date = gridStart.plus(row * 7 + col, DateTimeUnit.DAY)
val inMonth =
date.month == state.month.month && date.year == state.month.year
if (inMonth) {
DayCard(
date = date,
isToday = date == state.today,
data = state.cells[date],
onClick = { onOpenDay(date) },
modifier = Modifier.weight(1f),
// Spanning bars on their shared lanes.
week.spans.filter { it.lane < shownLanes }.forEach { span ->
val cols = span.endCol - span.startCol + 1
MonthBar(
event = span.event,
dark = dark,
continuesLeft = span.continuesLeft,
continuesRight = span.continuesRight,
modifier = Modifier
.offset(
x = colW * span.startCol,
y = EVENT_ROW_HEIGHT * span.lane,
)
.width(colW * cols)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
)
}
// Single-day timed pills + overflow, per column. Pills fill the
// lane slots no bar occupies on THIS day (top-most first), so a
// bar-free day isn't pushed down by a multi-day event that only
// sits on other days of the week.
week.days.forEachIndexed { col, d ->
val timed = week.timedByDay[d].orEmpty()
val occupied = week.spans
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
.map { it.lane }
.toSet()
val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied }
val pillsShown = timed.take(freeSlots.size)
pillsShown.forEachIndexed { i, ev ->
MonthBar(
event = ev,
dark = dark,
continuesLeft = false,
continuesRight = false,
modifier = Modifier
.offset(
x = colW * col,
y = EVENT_ROW_HEIGHT * freeSlots[i],
)
.width(colW)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
)
}
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
if (hidden > 0) {
val hiddenColors = buildList {
week.spans
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
.forEach { add(it.event.color) }
timed.drop(pillsShown.size).forEach { add(it.color) }
}.distinct().take(3)
OverflowDots(
colors = hiddenColors,
extra = hidden - hiddenColors.size,
dark = dark,
modifier = Modifier
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
.width(colW)
.padding(horizontal = 3.dp),
)
} else {
Spacer(Modifier.weight(1f))
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun DayCard(
date: LocalDate,
isToday: Boolean,
data: DayCellData?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val todayPrefix = stringResource(R.string.month_a11y_today_prefix)
val cellLabel = buildString {
if (isToday) append(todayPrefix).append(", ")
append(date.year).append('-')
append((date.month.ordinal + 1).toString().padStart(2, '0')).append('-')
append(date.day.toString().padStart(2, '0'))
data?.let { append(", ").append(it.count).append(" Events") }
}
// M3 Expressive press feedback: a spatial spring from the active motion
// scheme drives a subtle scale, instead of a fixed easing curve.
val interactionSource = remember { MutableInteractionSource() }
val pressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed) 0.94f else 1f,
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
label = "day-card-press",
)
Card(
onClick = onClick,
interactionSource = interactionSource,
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = if (isToday) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = if (isToday) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurface,
),
modifier = modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
// Tap layer: in month view a tap on any day opens that day. Padded and
// clipped to the background pill so the ripple matches it.
Row(Modifier.matchParentSize()) {
week.days.forEach { d ->
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.clip(CELL_SHAPE)
.clickable { onOpenDay(d) },
)
}
.semantics { contentDescription = cellLabel },
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = 4.dp, bottom = 2.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = date.day.toString(),
style = MaterialTheme.typography.labelLarge,
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
)
Spacer(Modifier.height(2.dp))
EventDotRow(data)
}
}
}
@Composable
private fun EventDotRow(data: DayCellData?) {
if (data == null || data.swatches.isEmpty()) {
Spacer(Modifier.height(6.dp))
return
private fun DayNumberCell(
date: LocalDate,
isToday: Boolean,
inMonth: Boolean,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.height(DAY_NUMBER_HEIGHT),
contentAlignment = Alignment.Center,
) {
if (isToday) {
Box(
modifier = Modifier
.size(DAY_NUMBER_HEIGHT)
.background(MaterialTheme.colorScheme.primary, CircleShape),
contentAlignment = Alignment.Center,
) {
Text(
text = date.day.toString(),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary,
)
}
} else {
Text(
text = date.day.toString(),
style = MaterialTheme.typography.labelMedium,
color = if (inMonth) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
)
}
}
val dark = isSystemInDarkTheme()
}
/** A filled event pill/bar — pastelized fill, title clipped to one line. */
@Composable
private fun MonthBar(
event: de.jeanlucmakiola.calendula.domain.EventInstance,
dark: Boolean,
continuesLeft: Boolean,
continuesRight: Boolean,
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val shape = RoundedCornerShape(
topStart = if (continuesLeft) 0.dp else 4.dp,
bottomStart = if (continuesLeft) 0.dp else 4.dp,
topEnd = if (continuesRight) 0.dp else 4.dp,
bottomEnd = if (continuesRight) 0.dp else 4.dp,
)
Box(
modifier = modifier
.background(pastelize(event.color, dark), shape)
.padding(horizontal = 4.dp)
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
) {
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.8f),
)
}
}
/** Overflow row: a dot per hidden event (up to three) plus "+N" for the rest. */
@Composable
private fun OverflowDots(
colors: List<Int>,
extra: Int,
dark: Boolean,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.height(EVENT_ROW_HEIGHT),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
data.swatches.forEach { argb ->
colors.forEach { argb ->
Box(
modifier = Modifier
.size(6.dp)
.background(pastelize(argb, dark), CircleShape),
)
}
if (data.count > data.swatches.size) {
if (extra > 0) {
Text(
text = "+${data.count - data.swatches.size}",
text = "+$extra",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)

View File

@@ -1,20 +1,40 @@
package de.jeanlucmakiola.calendula.ui.month
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.datetime.LocalDate
import kotlinx.datetime.YearMonth
/**
* Per-day aggregation surfaced to the month grid. We only need
* - the total event count (drives the optional "+N" indicator), and
* - up to three calendar colors for the dot row.
*
* The day cell never holds full event objects — the detail sheet pulls those
* lazily.
* An all-day or multi-day event laid out as one connected horizontal bar across
* a week row, [startCol]..[endCol], stacked on [lane] so overlapping spans don't
* collide. Mirrors the week view's [de.jeanlucmakiola.calendula.ui.week.AllDaySpan]
* but adds clip flags so a bar that started in an earlier week (or runs into a
* later one) drops its rounded cap on that side.
*/
data class DayCellData(
val count: Int,
val swatches: List<Int>,
data class MonthSpan(
val event: EventInstance,
val startCol: Int,
val endCol: Int,
val lane: Int,
val continuesLeft: Boolean,
val continuesRight: Boolean,
)
/**
* One week row of the grid with its events resolved for rendering.
*
* - [spans] are the all-day/multi-day bars, lanes already assigned for the row.
* - [timedByDay] holds the single-day timed events per date, sorted by start;
* these render as filled pills beneath the bar lanes in their own cell.
* - [countByDay] is the total number of events touching each date (bars + pills),
* so the cell can compute the "+N" overflow once the visible-row cap is known.
*/
data class MonthWeek(
val days: List<LocalDate>,
val spans: List<MonthSpan>,
val timedByDay: Map<LocalDate, List<EventInstance>>,
val countByDay: Map<LocalDate, Int>,
)
sealed interface MonthUiState {
@@ -23,6 +43,6 @@ sealed interface MonthUiState {
data class Success(
val month: YearMonth,
val today: LocalDate,
val cells: Map<LocalDate, DayCellData>,
val weeks: List<MonthWeek>,
) : MonthUiState
}

View File

@@ -10,6 +10,8 @@ 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 de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -71,7 +73,7 @@ class MonthViewModel @Inject constructor(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(ym, calendars, instances)
buildState(ym, ws, calendars, instances)
}
}
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
@@ -94,25 +96,73 @@ 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,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): MonthUiState {
if (calendars.isEmpty()) {
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val byDay = instances.groupBy { it.start.toLocalDateTime(zone).date }
.mapValues { (_, evs) ->
DayCellData(
count = evs.size,
swatches = evs.map { it.color }.distinct().take(3),
)
}
return MonthUiState.Success(
month = ym,
today = todayDate,
cells = byDay,
weeks = layoutMonthWeeks(ym, weekStart, instances, zone),
)
}
}
/**
* 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) } },
)
}
}

View File

@@ -0,0 +1,163 @@
package de.jeanlucmakiola.calendula.ui.permission
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
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.vector.ImageVector
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/** MD3 8dp spacing scale shared by the onboarding screens. */
internal object OnboardingSpace {
val xs = 8.dp
val sm = 16.dp
val md = 24.dp
val lg = 32.dp
val xl = 48.dp
}
/**
* Shared onboarding shell (calendar grant, reminder step): a scrollable,
* centred hero + body with the call(s) to action pinned to the bottom (clear
* of the navigation bar). The content slot is centred horizontally; benefit
* rows fill the width so their own content left-aligns.
*/
@Composable
internal fun OnboardingScaffold(
hero: @Composable () -> Unit,
actions: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
body: @Composable ColumnScope.() -> Unit,
) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.surface,
bottomBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
content = actions,
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = OnboardingSpace.md),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(OnboardingSpace.xl))
hero()
Spacer(Modifier.height(OnboardingSpace.lg))
body()
Spacer(Modifier.height(OnboardingSpace.md))
}
}
}
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
@Composable
internal fun BrandHero(denied: Boolean) {
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(128.dp)
.clip(RoundedCornerShape(34.dp))
.background(colorResource(R.color.ic_launcher_background)),
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = stringResource(R.string.app_name),
modifier = Modifier.fillMaxSize(),
)
}
if (denied) {
// A small lock badge sits over the corner to signal "blocked".
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = 10.dp, y = 10.dp)
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.errorContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(24.dp),
)
}
}
}
}
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
@Composable
internal fun BenefitRow(icon: ImageVector, title: String, body: String) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(22.dp),
)
}
Spacer(Modifier.width(OnboardingSpace.sm))
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View File

@@ -6,25 +6,14 @@ import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.CalendarMonth
@@ -34,7 +23,6 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -42,28 +30,19 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.foundation.Image
import de.jeanlucmakiola.calendula.R
// MD3 8dp spacing scale, scoped to this screen.
private object Space {
val xs = 8.dp
val sm = 16.dp
val md = 24.dp
val lg = 32.dp
val xl = 48.dp
}
private val CALENDAR_PERMISSIONS = arrayOf(
Manifest.permission.READ_CALENDAR,
Manifest.permission.WRITE_CALENDAR,
)
@Composable
fun PermissionScreen(
@@ -73,10 +52,17 @@ fun PermissionScreen(
) {
val state by viewModel.state.collectAsStateWithLifecycle()
// READ and WRITE are requested together (one system dialog — same
// permission group), but only READ gates the app: declining write keeps
// Calendula usable read-only.
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) viewModel.onGranted() else viewModel.onDenied()
contract = ActivityResultContracts.RequestMultiplePermissions(),
) { results ->
if (results[Manifest.permission.READ_CALENDAR] == true) {
viewModel.onGranted()
} else {
viewModel.onDenied()
}
}
LaunchedEffect(state) {
@@ -85,13 +71,13 @@ fun PermissionScreen(
when (state) {
is PermissionUiState.Rationale -> RationaleContent(
onRequest = { launcher.launch(Manifest.permission.READ_CALENDAR) },
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
modifier = modifier,
)
is PermissionUiState.Denied -> DeniedContent(
onRetry = {
viewModel.onRetry()
launcher.launch(Manifest.permission.READ_CALENDAR)
launcher.launch(CALENDAR_PERMISSIONS)
},
modifier = modifier,
)
@@ -106,7 +92,7 @@ private fun RationaleContent(
onRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
PermissionScaffold(
OnboardingScaffold(
modifier = modifier,
hero = { BrandHero(denied = false) },
actions = {
@@ -119,7 +105,7 @@ private fun RationaleContent(
text = stringResource(R.string.permission_request_button),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.width(Space.xs))
Spacer(Modifier.width(OnboardingSpace.xs))
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null,
@@ -135,7 +121,7 @@ private fun RationaleContent(
color = MaterialTheme.colorScheme.primary,
letterSpacing = 2.sp,
)
Spacer(Modifier.height(Space.xs))
Spacer(Modifier.height(OnboardingSpace.xs))
Text(
text = stringResource(R.string.permission_rationale_title),
style = MaterialTheme.typography.headlineMedium,
@@ -149,20 +135,20 @@ private fun RationaleContent(
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(Space.xl))
Spacer(Modifier.height(OnboardingSpace.xl))
BenefitRow(
icon = Icons.Filled.Lock,
title = stringResource(R.string.permission_benefit_private_title),
body = stringResource(R.string.permission_benefit_private_body),
)
Spacer(Modifier.height(Space.sm))
Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow(
icon = Icons.Filled.CalendarMonth,
title = stringResource(R.string.permission_benefit_sync_title),
body = stringResource(R.string.permission_benefit_sync_body),
)
Spacer(Modifier.height(Space.sm))
Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow(
icon = Icons.Filled.VisibilityOff,
title = stringResource(R.string.permission_benefit_privacy_title),
@@ -177,7 +163,7 @@ private fun DeniedContent(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
PermissionScaffold(
OnboardingScaffold(
modifier = modifier,
hero = { BrandHero(denied = true) },
actions = {
@@ -219,122 +205,6 @@ private fun DeniedContent(
}
}
/**
* Shared onboarding shell: a scrollable, centred hero + body with the call(s) to
* action pinned to the bottom (clear of the navigation bar). The content slot is
* centred horizontally; benefit rows fill the width so their own content
* left-aligns.
*/
@Composable
private fun PermissionScaffold(
hero: @Composable () -> Unit,
actions: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
body: @Composable ColumnScope.() -> Unit,
) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.surface,
bottomBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = Space.md, vertical = Space.sm),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
content = actions,
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = Space.md),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(Space.xl))
hero()
Spacer(Modifier.height(Space.lg))
body()
Spacer(Modifier.height(Space.md))
}
}
}
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
@Composable
private fun BrandHero(denied: Boolean) {
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(128.dp)
.clip(RoundedCornerShape(34.dp))
.background(colorResource(R.color.ic_launcher_background)),
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = stringResource(R.string.app_name),
modifier = Modifier.fillMaxSize(),
)
}
if (denied) {
// A small lock badge sits over the corner to signal "blocked".
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = 10.dp, y = 10.dp)
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.errorContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(24.dp),
)
}
}
}
}
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
@Composable
private fun BenefitRow(icon: ImageVector, title: String, body: String) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(22.dp),
)
}
Spacer(Modifier.width(Space.sm))
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun PrivacyFootnote() {
Row(

View File

@@ -0,0 +1,138 @@
package de.jeanlucmakiola.calendula.ui.permission
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.calendula.R
/**
* One-time onboarding step after the calendar grant (v1.4): explains that
* Calendula delivers reminder notifications itself, warns about duplicates
* when a second calendar app has notifications on, and requests
* `POST_NOTIFICATIONS` (a system dialog on API 33+ only; minSdk is 29).
*
* Reminders default ON: [onFinished] gets true from the primary action even
* if the system dialog is declined — the OS permission is the real gate, and
* the Settings toggle re-requests it. "Not now" turns the in-app toggle off.
*/
@Composable
fun ReminderOnboardingScreen(
onFinished: (remindersEnabled: Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { onFinished(true) }
OnboardingScaffold(
modifier = modifier,
hero = { BellHero() },
actions = {
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
onFinished(true)
}
},
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.reminder_onboarding_enable_button),
style = MaterialTheme.typography.titleMedium,
)
}
TextButton(
onClick = { onFinished(false) },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.reminder_onboarding_skip_button))
}
},
) {
Text(
text = stringResource(R.string.app_name).uppercase(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
letterSpacing = 2.sp,
)
Spacer(Modifier.height(OnboardingSpace.xs))
Text(
text = stringResource(R.string.reminder_onboarding_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.reminder_onboarding_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(OnboardingSpace.xl))
BenefitRow(
icon = Icons.Filled.NotificationsActive,
title = stringResource(R.string.reminder_benefit_delivery_title),
body = stringResource(R.string.reminder_benefit_delivery_body),
)
Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow(
icon = Icons.Filled.ContentCopy,
title = stringResource(R.string.reminder_benefit_duplicates_title),
body = stringResource(R.string.reminder_benefit_duplicates_body),
)
Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow(
icon = Icons.Filled.Tune,
title = stringResource(R.string.reminder_benefit_reversible_title),
body = stringResource(R.string.reminder_benefit_reversible_body),
)
}
}
/** A bell in the brand squircle — same silhouette as the permission hero. */
@Composable
private fun BellHero() {
Box(
modifier = Modifier
.size(128.dp)
.clip(RoundedCornerShape(34.dp))
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.NotificationsActive,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
}
}

View File

@@ -0,0 +1,39 @@
package de.jeanlucmakiola.calendula.ui.permission
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Gates the one-time reminder onboarding step (v1.4) shown after the calendar
* grant. [onboardingDone] is null until DataStore's first emission so the
* step neither flashes for users who completed it nor gets skipped.
*/
@HiltViewModel
class ReminderOnboardingViewModel @Inject constructor(
private val prefs: SettingsPrefs,
) : ViewModel() {
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
.map { done -> done as Boolean? }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
/** Close the step, recording whether reminder notifications stay on. */
fun finish(remindersEnabled: Boolean) {
viewModelScope.launch {
prefs.setRemindersEnabled(remindersEnabled)
prefs.setReminderOnboardingDone()
}
}
}

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,7 +1,10 @@
package de.jeanlucmakiola.calendula.ui.settings
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
/**
* Settings screen state (M4). Persisted preferences are instant to read, so
@@ -14,4 +17,32 @@ data class SettingsUiState(
val dynamicColor: Boolean = true,
val dynamicColorAvailable: Boolean = true,
val weekStart: WeekStartPref = WeekStartPref.AUTO,
/** Optional event-form fields shown by default (rest behind "more fields"). */
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
/** 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,
/**
* 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

@@ -4,12 +4,19 @@ import android.os.Build
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.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
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 javax.inject.Inject
@@ -17,21 +24,60 @@ import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val prefs: SettingsPrefs,
repository: CalendarRepository,
) : 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,
) { theme, dynamic, weekStart ->
SettingsUiState(
themeMode = theme,
dynamicColor = dynamic && dynamicColorAvailable,
dynamicColorAvailable = dynamicColorAvailable,
weekStart = weekStart,
// 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,
) { allowColor, defaultReminder, allDayReminder, allDayReminderTime ->
ReminderDefaults(allowColor, defaultReminder, allDayReminder, allDayReminderTime)
},
combine(
prefs.perCalendarReminderOverride,
prefs.perCalendarAllDayReminderOverride,
writableCalendars,
) { overrides, allDayOverrides, calendars ->
ReminderOverrides(overrides, allDayOverrides, calendars)
},
) { base, defaults, overrides ->
base.copy(
allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder,
allDayReminderTimeMinutes = defaults.allDayReminderTime,
perCalendarReminderOverride = overrides.timed,
perCalendarAllDayReminderOverride = overrides.allDay,
writableCalendars = overrides.calendars,
)
}.stateIn(
scope = viewModelScope,
@@ -39,6 +85,19 @@ class SettingsViewModel @Inject constructor(
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
)
private data class ReminderDefaults(
val allowColor: Boolean,
val defaultReminder: Int?,
val allDayReminder: Int?,
val allDayReminderTime: Int,
)
private data class ReminderOverrides(
val timed: Map<Long, Int?>,
val allDay: Map<Long, Int?>,
val calendars: List<CalendarSource>,
)
fun setThemeMode(mode: ThemeMode) {
viewModelScope.launch { prefs.setThemeMode(mode) }
}
@@ -50,4 +109,36 @@ class SettingsViewModel @Inject constructor(
fun setWeekStart(pref: WeekStartPref) {
viewModelScope.launch { prefs.setWeekStart(pref) }
}
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
}
fun setRemindersEnabled(enabled: Boolean) {
viewModelScope.launch { prefs.setRemindersEnabled(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 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

@@ -2,7 +2,9 @@ package de.jeanlucmakiola.calendula.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
@@ -17,6 +19,7 @@ import androidx.compose.ui.platform.LocalContext
* The Settings screen (later) can override useDynamicColor and themePreference,
* but the V1 foundation just follows the system.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun CalendulaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
@@ -32,9 +35,15 @@ fun CalendulaTheme(
else -> CalendulaLightFallback
}
MaterialTheme(
// MaterialExpressiveTheme routes all component + custom motion through
// MaterialTheme.motionScheme (switches, chips, pickers, calendar slide,
// FAB, field reveal). The STANDARD scheme is a deliberate choice over
// expressive(): same spring choreography, but without the overshoot —
// the bouncy variant felt overdone in review (2026-06-11).
MaterialExpressiveTheme(
colorScheme = colorScheme,
typography = CalendulaTypography,
motionScheme = MotionScheme.standard(),
content = content,
)
}

View File

@@ -1,14 +1,12 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -31,12 +29,10 @@ 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.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -77,6 +73,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
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.ViewSwitcherPill
@@ -88,7 +85,10 @@ import de.jeanlucmakiola.calendula.ui.common.pastelize
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import kotlin.math.roundToInt
@@ -113,6 +113,7 @@ fun WeekScreen(
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(),
) {
@@ -146,7 +147,20 @@ fun WeekScreen(
var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() }
val goPrev = { slideDir = -1; viewModel.goToPrev() }
val jumpToToday = { slideDir = 0; viewModel.goToToday() }
// Slide toward today: viewing the future → today comes in from the left
// (back), viewing the past → from the right (forward).
val jumpToToday = {
slideDir = when (val s = state) {
is WeekUiState.Success -> if (s.today < s.weekStart) -1 else 1
else -> 0
}
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,
@@ -154,7 +168,16 @@ fun WeekScreen(
gesturesEnabled = drawerState.isOpen,
drawerContent = {
CalendarDrawer(
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
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() }
@@ -174,17 +197,17 @@ fun WeekScreen(
)
},
floatingActionButton = {
AnimatedVisibility(
visible = !isOnCurrentWeek,
enter = scaleIn(),
exit = scaleOut(),
) {
ExtendedFloatingActionButton(
onClick = jumpToToday,
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
text = { Text(stringResource(R.string.week_today_action)) },
)
}
CalendarFabColumn(
todayVisible = !isOnCurrentWeek,
todayText = stringResource(R.string.week_today_action),
onToday = jumpToToday,
onCreate = {
// Anchor on today when it's in view, else the week's first day.
val today = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date
onCreateEvent(if (isOnCurrentWeek) today else weekStart, null)
},
)
},
) { innerPadding ->
WeekContent(
@@ -195,6 +218,7 @@ fun WeekScreen(
onSwipePrev = goPrev,
onRetry = jumpToToday,
onEventClick = onEventClick,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
@@ -212,6 +236,7 @@ private fun WeekContent(
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
@@ -283,6 +308,7 @@ private fun WeekContent(
scrollState = scrollState,
allDayHeight = allDayHeight,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
)
}
}
@@ -295,6 +321,7 @@ private fun WeekSuccess(
scrollState: ScrollState,
allDayHeight: Dp,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
@@ -308,7 +335,12 @@ private fun WeekSuccess(
// Breathing room between the (colour-shifting) top section and the
// scrolling timeline below.
Spacer(Modifier.height(8.dp))
Timeline(state = state, scrollState = scrollState, onEventClick = onEventClick)
Timeline(
state = state,
scrollState = scrollState,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
)
}
}
@@ -521,6 +553,7 @@ private fun Timeline(
state: WeekUiState.Success,
scrollState: ScrollState,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
) {
val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme()
@@ -576,7 +609,9 @@ private fun Timeline(
DayColumnCard(
blocks = state.timedByDay[day].orEmpty(),
dark = dark,
date = day,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
@@ -592,9 +627,12 @@ private fun Timeline(
private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
date: LocalDate,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
) {
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
Card(
// Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -604,7 +642,18 @@ private fun DayColumnCard(
),
modifier = modifier,
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
// 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) {
detectTapGestures { offset ->
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
onCreateAt(date, hour * 60)
}
},
) {
val colWidth = maxWidth
blocks.forEach { block ->
val laneWidth = colWidth / block.laneCount

View File

@@ -107,6 +107,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>,

View File

@@ -0,0 +1,113 @@
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.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.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
/** How far ahead the agenda widget loads (a month of upcoming events). */
private const val AGENDA_WIDGET_DAYS = 30
/**
* 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, val days: List<AgendaDay>) : 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 repo = widgetEntryPoint().calendarRepository()
val instances = repo.instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first()
return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone))
}
/** 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,279 @@
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.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.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.domain.EventInstance
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.
*/
class AgendaWidget : GlanceAppWidget() {
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 ->
if (data.days.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title)
} else {
val rows = buildList {
data.days.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(row.event, dark)
}
}
}
}
}
}
}
@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))),
)
}
@Composable
private fun EventRow(event: EventInstance, dark: Boolean) {
val context = androidx.glance.LocalContext.current
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 4.dp)
.clickable(
actionStartActivity(
MainActivity.eventDetailIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
),
),
),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = GlanceModifier
.width(5.dp)
.height(36.dp)
.cornerRadius(3.dp)
.background(pastelize(event.color, dark)),
) {}
Spacer(GlanceModifier.width(10.dp))
Column(modifier = GlanceModifier.defaultWeight()) {
Text(
text = title,
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
)
Text(
text = eventTimeSummary(context, event),
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): String {
val time = if (event.isAllDay) {
context.getString(R.string.event_detail_all_day)
} else {
"${formatTime(event.start)} ${formatTime(event.end)}"
}
val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time
}
private fun formatTime(instant: Instant): String {
val t = instant.toLocalDateTime(zone()).time
return "%02d:%02d".format(t.hour, t.minute)
}

View File

@@ -0,0 +1,13 @@
package de.jeanlucmakiola.calendula.widget.agenda
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
/**
* Host-facing receiver for the agenda widget. Declared in the manifest with the
* `appwidget_info_agenda` provider metadata; delegates all rendering to
* [AgendaWidget].
*/
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
}

View File

@@ -0,0 +1,433 @@
package de.jeanlucmakiola.calendula.widget.month
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.intPreferencesKey
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.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
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.provideContent
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.appwidget.updateAll
import androidx.glance.background
import androidx.glance.currentState
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.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import androidx.compose.ui.unit.Dp
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
private val MONTH_INDEX_KEY = intPreferencesKey("month_index")
/** Event rows (lanes) shown per week before the rest collapse into "+N". */
private const val MAX_LANES = 3
private val LANE_HEIGHT = 14.dp
private val DAY_NUMBER_HEIGHT = 18.dp
private val GRID_HPADDING = 8.dp
/** Dark ink that reads on the pastelized event fills, like the in-app MonthBar. */
private val EventInk = ColorProvider(Color(0xDE000000))
private fun currentMonthIndex(zone: TimeZone): Int {
val t = today(zone)
return t.year * 12 + t.month.ordinal
}
private fun yearMonthOf(index: Int): YearMonth =
YearMonth(index / 12, Month(index % 12 + 1))
/**
* Month-grid widget: a 6×7 calendar with today highlighted, connected multi-day
* event bars and titled single-day pills (the in-app lane layout via
* [layoutMonthWeeks]), and prev/next/today navigation.
*
* Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a
* multi-day span renders as a single Box spanning its columns — connected, no
* inter-cell seam, with rounded end caps. The displayed month lives in Glance
* state and is read reactively in the composition ([currentState]) so the arrows
* move it via plain recomposition, not a (here-unreliable) widget session reload.
*/
class MonthWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
val source = context.loadMonthWidgetSource()
val dark = (context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
provideContent {
CalendulaGlanceTheme {
MonthWidgetBody(source = source, dark = dark)
}
}
}
}
/** Step the displayed month by the `delta` action parameter (±1). */
class ShiftMonthAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val delta = parameters[deltaKey] ?: 0
updateAppWidgetState(context, glanceId) { prefs ->
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
prefs[MONTH_INDEX_KEY] = cur + delta
}
MonthWidget().updateAll(context.applicationContext)
}
companion object {
val deltaKey = ActionParameters.Key<Int>("delta")
}
}
/** Jump the displayed month back to the current month. */
class ResetMonthAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
MonthWidget().updateAll(context.applicationContext)
}
}
@Composable
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.surface)
.padding(horizontal = GRID_HPADDING, vertical = 6.dp),
) {
when (source) {
MonthWidgetSource.NeedsPermission -> {
MonthHeader(label = "Calendula")
PermissionMessage()
}
is MonthWidgetSource.Ready -> {
val zone = systemZone()
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
val ym = yearMonthOf(index)
// Column width from the live widget size, minus our H padding.
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
MonthHeader(label = monthLabel(ym))
Spacer(GlanceModifier.height(2.dp))
WeekdayHeader(weekStart = source.weekStart, colW = colW)
weeks.forEach { week ->
WeekRow(
week = week,
currentMonth = ym.month,
today = source.today,
dark = dark,
colW = colW,
modifier = GlanceModifier.defaultWeight(),
)
}
}
}
}
}
@Composable
private fun MonthHeader(label: String) {
val context = LocalContext.current
Row(
modifier = GlanceModifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
HeaderIcon(
resId = R.drawable.ic_widget_chevron_left,
contentDescription = context.getString(R.string.widget_prev_month),
onClick = GlanceModifier.clickable(
actionRunCallback<ShiftMonthAction>(
actionParametersOf(ShiftMonthAction.deltaKey to -1),
),
),
)
Text(
text = label,
style = TextStyle(
color = GlanceTheme.colors.primary,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
),
modifier = GlanceModifier
.defaultWeight()
.clickable(actionRunCallback<ResetMonthAction>()),
)
HeaderIcon(
resId = R.drawable.ic_widget_today,
contentDescription = context.getString(R.string.widget_today),
onClick = GlanceModifier.clickable(
actionStartActivity(MainActivity.openDateIntent(context, today(systemZone()))),
),
)
HeaderIcon(
resId = R.drawable.ic_widget_chevron_right,
contentDescription = context.getString(R.string.widget_next_month),
onClick = GlanceModifier.clickable(
actionRunCallback<ShiftMonthAction>(
actionParametersOf(ShiftMonthAction.deltaKey to 1),
),
),
)
}
}
@Composable
private fun HeaderIcon(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(20.dp),
)
}
}
@Composable
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
weekdayNarrowNames(weekStart).forEach { name ->
Text(
text = name,
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 11.sp,
textAlign = TextAlign.Center,
),
modifier = GlanceModifier.width(colW),
)
}
}
}
/** Narrow weekday initials starting at [weekStart], in the device locale.
* Computed outside the composable so the locale read stays observable-safe. */
private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
val locale = Locale.getDefault()
return (0 until 7).map { i ->
java.time.DayOfWeek.of((weekStart.ordinal + i) % 7 + 1)
.getDisplayName(JavaTextStyle.NARROW, locale)
}
}
@Composable
private fun WeekRow(
week: MonthWeek,
currentMonth: Month,
today: LocalDate,
dark: Boolean,
colW: Dp,
modifier: GlanceModifier,
) {
Column(modifier = modifier.fillMaxWidth()) {
// Day numbers.
Row(modifier = GlanceModifier.fillMaxWidth()) {
week.days.forEach { date ->
DayNumber(
date = date,
isToday = date == today,
inMonth = date.month == currentMonth,
colW = colW,
)
}
}
Spacer(GlanceModifier.height(2.dp))
// One lane row per event row. A multi-day span is a single Box spanning
// its columns (colW * n) so it's connected with no seam and rounded ends.
repeat(MAX_LANES) { lane ->
LaneRow(week = week, lane = lane, dark = dark, colW = colW)
Spacer(GlanceModifier.height(1.dp))
}
OverflowRow(week = week, colW = colW)
}
}
@Composable
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
Box(
modifier = GlanceModifier.width(colW).height(DAY_NUMBER_HEIGHT),
contentAlignment = Alignment.Center,
) {
Box(
modifier = GlanceModifier
.size(DAY_NUMBER_HEIGHT)
.then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
contentAlignment = Alignment.Center,
) {
Text(
text = date.day.toString(),
style = TextStyle(
color = when {
isToday -> GlanceTheme.colors.onPrimary
inMonth -> GlanceTheme.colors.onSurface
else -> GlanceTheme.colors.onSurfaceVariant
},
fontSize = 11.sp,
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
),
)
}
}
}
@Composable
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
var col = 0
while (col < 7) {
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
if (span != null) {
val cols = span.endCol - col + 1
SpanBar(event = span.event, dark = dark, width = colW * cols)
col = span.endCol + 1
} else {
val timed = timedEventAt(week, lane, col, week.days[col])
if (timed != null) {
SpanBar(event = timed, dark = dark, width = colW)
} else {
Box(GlanceModifier.width(colW).height(LANE_HEIGHT)) {}
}
col += 1
}
}
}
}
/** A single connected, rounded event bar [width] wide with its clipped title. */
@Composable
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
val context = LocalContext.current
Box(modifier = GlanceModifier.width(width).height(LANE_HEIGHT).padding(horizontal = 1.dp)) {
Box(
modifier = GlanceModifier
.fillMaxSize()
.cornerRadius(4.dp)
.background(pastelize(event.color, dark)),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1,
style = TextStyle(color = EventInk, fontSize = 9.sp),
modifier = GlanceModifier.padding(horizontal = 3.dp),
)
}
}
}
@Composable
private fun OverflowRow(week: MonthWeek, colW: Dp) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
week.days.forEachIndexed { col, date ->
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
Box(
modifier = GlanceModifier.width(colW).height(LANE_HEIGHT),
contentAlignment = Alignment.CenterStart,
) {
if (hidden > 0) {
Text(
text = "+$hidden",
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
modifier = GlanceModifier.padding(start = 3.dp),
)
}
}
}
}
}
/** The timed single-day event that fills lane [lane] on day [col], if any. */
private fun timedEventAt(week: MonthWeek, lane: Int, col: Int, date: LocalDate): EventInstance? {
val occupied = week.spans
.filter { col in it.startCol..it.endCol && it.lane < MAX_LANES }
.map { it.lane }
.toSet()
val freeSlots = (0 until MAX_LANES).filter { it !in occupied }
val timed = week.timedByDay[date].orEmpty()
return timed.getOrNull(freeSlots.indexOf(lane))
}
@Composable
private fun PermissionMessage() {
val context = LocalContext.current
Box(
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = context.getString(R.string.widget_needs_permission),
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 14.sp,
textAlign = TextAlign.Center,
),
)
}
}
private fun monthLabel(month: YearMonth): String {
val locale = Locale.getDefault()
val name = java.time.Month.of(month.month.ordinal + 1)
.getDisplayName(JavaTextStyle.FULL, locale)
return "$name ${month.year}"
}

View File

@@ -0,0 +1,12 @@
package de.jeanlucmakiola.calendula.widget.month
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
/**
* Host-facing receiver for the month widget. Declared in the manifest with the
* `appwidget_info_month` provider metadata; delegates rendering to [MonthWidget].
*/
class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = MonthWidget()
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Gitea brand mark, used on the "Source" button in Settings → About.
Single-path logo from Simple Icons (https://simpleicons.org, CC0),
pathData kept verbatim so Android's PathParser reads the arc flags.
fillColor is a placeholder; the Compose Icon recolours it via tint.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Monochrome status-bar mark: Material "event" calendar glyph. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM19,19H5V8h14V19zM7,10h5v5H7V10z" />
</vector>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Launcher long-press shortcut icon ("New event"): a brand-coloured circle
with a white calendar+plus glyph, as one scalable vector. Self-contained so
it stays visible on any launcher surface (shortcut icons aren't tinted, so a
bare white glyph would vanish on a light sheet). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#3B5364"
android:pathData="M0,12 a12,12 0 1,0 24,0 a12,12 0 1,0 -24,0 Z" />
<group
android:scaleX="0.58"
android:scaleY="0.58"
android:translateX="5"
android:translateY="5">
<path
android:fillColor="@android:color/white"
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM19,19H5V8h14V19z" />
<path
android:fillColor="@android:color/white"
android:pathData="M11.5,10.5h1v2h2v1h-2v2h-1v-2h-2v-1h2z" />
</group>
</vector>

View File

@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/white"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>

View File

@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/white"
android:pathData="M15.41,7.41L14,6l-6,6 6,6 1.41,-1.41L10.83,12z"/>
</vector>

View File

@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/white"
android:pathData="M10,6L8.59,7.41 13.17,12l-4.58,4.59L10,18l6,-6z"/>
</vector>

View File

@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/white"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>

View File

@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/white"
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM19,19H5V8h14v11zM7,10h5v5H7z"/>
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#4F7CAC" />
<corners android:radius="3dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/widget_preview_primary" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_preview_surface" />
<corners android:radius="20dp" />
</shape>

View File

@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker (android:previewLayout). Not the
runtime layout — the live widget is rendered by Glance. Colours are the
brand light scheme so the picker preview reads on its light sheet. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@drawable/preview_widget_bg"
android:padding="14dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/widget_agenda_title"
android:textColor="@color/widget_preview_primary"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/agenda_header_today"
android:textColor="@color/widget_preview_primary"
android:textSize="12sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageView
android:layout_width="5dp"
android:layout_height="34dp"
android:contentDescription="@null"
android:background="@drawable/preview_stripe"
android:backgroundTint="#4F7CAC" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Standup"
android:textColor="@color/widget_preview_on_surface"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="09:00 09:30"
android:textColor="@color/widget_preview_variant"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageView
android:layout_width="5dp"
android:layout_height="34dp"
android:contentDescription="@null"
android:background="@drawable/preview_stripe"
android:backgroundTint="#6FA37A" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Design review"
android:textColor="@color/widget_preview_on_surface"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="11:30 12:30"
android:textColor="@color/widget_preview_variant"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageView
android:layout_width="5dp"
android:layout_height="34dp"
android:contentDescription="@null"
android:background="@drawable/preview_stripe"
android:backgroundTint="#C58A56" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="1:1 with Mara"
android:textColor="@color/widget_preview_on_surface"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="14:00 14:30"
android:textColor="@color/widget_preview_variant"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker (android:previewLayout). The live
widget is rendered by Glance with real events; this only needs to read as
"a month grid" in the picker. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@drawable/preview_widget_bg"
android:padding="12dp">
<!-- Header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:textColor="@color/widget_preview_variant"
android:textSize="16sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="June 2026"
android:textColor="@color/widget_preview_primary"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:textColor="@color/widget_preview_variant"
android:textSize="16sp" />
</LinearLayout>
<!-- Weekday header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:orientation="horizontal">
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="M" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="T" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="W" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="T" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="F" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="S" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="S" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
</LinearLayout>
<!-- Week 1: 1..7 (event bar on the 3rd) -->
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="1" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="2" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="3" android:textColor="#4F7CAC" android:textStyle="bold" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="4" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="5" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="6" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="7" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
</LinearLayout>
<!-- Week 2: 8..14 (bars on 11,12,13) -->
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="8" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="9" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="10" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="11" android:textColor="#6FA37A" android:textStyle="bold" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="12" android:textColor="#6FA37A" android:textStyle="bold" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="13" android:textColor="#6FA37A" android:textStyle="bold" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="14" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
</LinearLayout>
<!-- Week 3: 15..21 (17 = today) -->
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="15" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="16" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:orientation="horizontal">
<TextView android:layout_width="22dp" android:layout_height="22dp" android:gravity="center"
android:text="17" android:textColor="@color/widget_preview_on_primary" android:textStyle="bold" android:textSize="12sp"
android:background="@drawable/preview_today_circle" />
</LinearLayout>
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="18" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="19" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="20" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="21" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
</LinearLayout>
<!-- Week 4: 22..28 (bar on 24) -->
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="22" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="23" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="24" android:textColor="#C58A56" android:textStyle="bold" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="25" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="26" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="27" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="28" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
</LinearLayout>
<!-- Week 5: 29, 30, then trailing July days greyed -->
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="29" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="30" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="1" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="2" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="3" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="4" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="5" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
</LinearLayout>
</LinearLayout>

View File

@@ -13,7 +13,7 @@
<!-- Permission-Flow (F1) -->
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
<string name="permission_rationale_body">Calendula braucht Lesezugriff auf deinen Kalender, um deine Termine zu zeigen. Mehr verlangt die App nie.</string>
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie.</string>
<string name="permission_request_button">Kalender-Zugriff erlauben</string>
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
@@ -25,7 +25,7 @@
<string name="permission_benefit_sync_body">Google, CalDAV, lokal — alles, was synchronisiert ist, erscheint automatisch.</string>
<string name="permission_benefit_privacy_title">Kein Tracking, niemals</string>
<string name="permission_benefit_privacy_body">Keine Telemetrie, keine Analyse, keine Werbung.</string>
<string name="permission_privacy_footnote">Nur Lesezugriff · keine Internet-Berechtigung</string>
<string name="permission_privacy_footnote">Bleibt auf deinem Gerät · keine Internet-Berechtigung</string>
<!-- Monatsansicht (S1) -->
<string name="month_prev">Vorheriger Monat</string>
@@ -47,6 +47,77 @@
<string name="event_detail_back">Zurück</string>
<string name="event_detail_edit">Bearbeiten</string>
<string name="event_detail_delete">Löschen</string>
<string name="event_delete_title">Termin löschen?</string>
<string name="event_delete_body">Der Termin wird aus deinem Kalender und von allen synchronisierten Geräten entfernt.</string>
<string name="event_delete_recurring_title">Wiederkehrenden Termin löschen</string>
<string name="event_delete_option_occurrence">Nur dieser Termin</string>
<string name="event_delete_option_following">Dieser und alle folgenden Termine</string>
<string name="event_delete_option_series">Alle Termine der Serie</string>
<string name="event_edit_recurring_title">Wiederkehrenden Termin bearbeiten</string>
<string name="event_delete_failed">Termin konnte nicht gelöscht werden</string>
<string name="event_delete_write_denied">Calendula braucht Schreibzugriff, um Termine zu löschen</string>
<string name="dialog_cancel">Abbrechen</string>
<string name="dialog_ok">OK</string>
<!-- Termin-Formular (v1.2 Erstellen) -->
<string name="event_edit_new_title">Neuer Termin</string>
<string name="event_edit_close">Schließen</string>
<string name="event_edit_save">Speichern</string>
<string name="event_edit_title_hint">Titel hinzufügen</string>
<string name="event_edit_starts">Beginn</string>
<string name="event_edit_ends">Ende</string>
<string name="event_edit_error_end_before_start">Endet vor dem Beginn</string>
<string name="event_edit_error_no_calendar">Kein beschreibbarer Kalender verfügbar</string>
<string name="event_edit_save_failed">Termin konnte nicht gespeichert werden</string>
<string name="event_edit_write_denied">Calendula braucht Schreibzugriff, um Termine zu erstellen</string>
<string name="event_edit_more_fields">Weitere Felder</string>
<string name="event_edit_add">Hinzufügen</string>
<string name="event_edit_add_reminder">Erinnerung hinzufügen</string>
<string name="event_edit_remove_reminder">Erinnerung entfernen</string>
<string name="event_edit_reminder_custom">Benutzerdefiniert</string>
<string name="reminder_unit_minutes">Minuten</string>
<string name="reminder_unit_hours">Stunden</string>
<string name="reminder_unit_days">Tage</string>
<string name="reminder_unit_weeks">Wochen</string>
<string name="event_edit_availability">Verfügbarkeit</string>
<string name="event_edit_visibility">Sichtbarkeit</string>
<!-- Termin-Formular — eigene Terminfarbe -->
<string name="event_edit_color">Farbe</string>
<string name="event_edit_color_default">Kalenderfarbe</string>
<string name="event_edit_color_custom">Eigene Farbe</string>
<string name="event_edit_color_reset">Zurücksetzen</string>
<string name="event_edit_color_unsupported">Für diesen Kalender nicht verfügbar</string>
<string name="event_edit_color_unsupported_hint">Dieser Kalender stellt keine Farbpalette bereit. Du kannst eigene Farben für solche Kalender in den Einstellungen aktivieren.</string>
<string name="event_edit_color_sync_warning">Dieser Kalender verwirft oder überschreibt die Farbe unter Umständen bei der nächsten Synchronisierung.</string>
<!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
<string name="event_edit_conflict_title">Termin wurde extern geändert</string>
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
<string name="event_edit_conflict_overwrite">Meine Änderungen speichern</string>
<string name="event_edit_conflict_overwrite_hint">Nur von dir bearbeitete Felder überschreiben die externe Änderung</string>
<string name="event_edit_conflict_discard">Meine Änderungen verwerfen</string>
<string name="event_edit_conflict_discard_hint">Der Termin bleibt, wie er jetzt ist</string>
<string name="event_edit_gone_title">Termin wurde gelöscht</string>
<string name="event_edit_gone_body">Dieser Termin wurde zwischenzeitlich gelöscht, etwa auf einem anderen Gerät. Deine Änderungen können nicht mehr gespeichert werden.</string>
<!-- Termin-Formular — Wiederholungs-Picker (v1.3) -->
<string name="event_edit_recurrence_none">Wiederholt sich nicht</string>
<string name="event_edit_recurrence_custom">Benutzerdefiniert</string>
<string name="event_edit_recurrence_every">Alle</string>
<string name="recurrence_unit_days">Tage</string>
<string name="recurrence_unit_weeks">Wochen</string>
<string name="recurrence_unit_months">Monate</string>
<string name="recurrence_unit_years">Jahre</string>
<string name="event_edit_recurrence_ends">Endet</string>
<string name="event_edit_recurrence_end_never">Nie</string>
<string name="event_edit_recurrence_end_until">An einem Datum</string>
<string name="event_edit_recurrence_end_count">Nach einer Anzahl</string>
<string name="event_edit_recurrence_times">Mal</string>
<string name="event_edit_error_recurrence_ends_before_start">Wiederholung endet vor dem Beginn</string>
<string name="event_availability_busy">Beschäftigt</string>
<string name="event_access_default">Standard</string>
<string name="event_access_public">Öffentlich</string>
<string name="event_detail_all_day">Ganztägig</string>
<string name="event_detail_calendar">Kalender</string>
<string name="event_detail_calendar_unknown">Unbekannter Kalender</string>
@@ -107,10 +178,51 @@
<!-- Geteilte Event-Strings -->
<string name="event_untitled">(Ohne Titel)</string>
<!-- Erinnerungs-Benachrichtigungen (v1.4) -->
<string name="reminder_channel_name">Termin-Erinnerungen</string>
<string name="reminder_channel_description">Benachrichtigungen zu den Erinnerungszeiten deiner Termine</string>
<string name="reminder_onboarding_title">Keinen Termin mehr verpassen</string>
<string name="reminder_onboarding_body">Android zeigt Termin-Erinnerungen nicht von selbst — das muss eine Kalender-App tun. Überlass Calendula den Job.</string>
<string name="reminder_benefit_delivery_title">Erinnerungen, zugestellt</string>
<string name="reminder_benefit_delivery_body">Jede Erinnerung an deinen Terminen kommt pünktlich als Benachrichtigung an.</string>
<string name="reminder_benefit_duplicates_title">Noch eine zweite Kalender-App?</string>
<string name="reminder_benefit_duplicates_body">Wenn eine andere App ebenfalls Erinnerungen zeigt, siehst du sie doppelt — schalte sie dort oder hier ab.</string>
<string name="reminder_benefit_reversible_title">Jederzeit änderbar</string>
<string name="reminder_benefit_reversible_body">Der Schalter liegt in den Einstellungen unter Benachrichtigungen.</string>
<string name="reminder_onboarding_enable_button">Erinnerungen einschalten</string>
<string name="reminder_onboarding_skip_button">Später</string>
<!-- View-Switcher (M1) -->
<string name="view_month">Monat</string>
<string name="view_week">Woche</string>
<string name="view_day">Tag</string>
<string name="view_agenda">Agenda</string>
<string name="view_section">Ansicht</string>
<!-- Zu Datum springen (Navigationsleiste) -->
<string name="drawer_jump_to_date">Zu Datum springen</string>
<!-- Agenda-Ansicht -->
<string name="agenda_today_action">Heute</string>
<string name="agenda_header_today">Heute</string>
<string name="agenda_header_tomorrow">Morgen</string>
<string name="agenda_empty_title">Nichts geplant</string>
<string name="agenda_empty_subtitle">Anstehende Termine erscheinen hier.</string>
<!-- Startbildschirm-Widgets -->
<string name="widget_agenda_title">Anstehend</string>
<string name="widget_agenda_label">Calendula Agenda</string>
<string name="widget_month_label">Calendula Monat</string>
<string name="widget_refresh">Aktualisieren</string>
<string name="widget_new_event">Neuer Termin</string>
<string name="widget_needs_permission">Öffne Calendula, um Kalenderzugriff zu erlauben</string>
<string name="widget_prev_month">Vorheriger Monat</string>
<string name="widget_next_month">Nächster Monat</string>
<string name="widget_today">Heute</string>
<!-- Verknüpfungen (Long-Press auf das App-Symbol) -->
<string name="shortcut_new_event_short">Neuer Termin</string>
<string name="shortcut_new_event_long">Neuen Termin erstellen</string>
<!-- Kalender-Filter (M3) -->
<string name="filter_title">Kalender</string>
@@ -129,15 +241,60 @@
<string name="settings_week_start_auto">Automatisch</string>
<string name="settings_week_start_monday">Montag</string>
<string name="settings_week_start_sunday">Sonntag</string>
<string name="settings_section_event_form">Termin-Formular</string>
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
<string name="settings_color_unsupported_hint">Manche Kalender (z. B. bestimmte CalDAV) stellen keine Farbpalette bereit; eine eigene Terminfarbe wird dort bei der nächsten Synchronisierung unter Umständen verworfen oder überschrieben. Das ist eine Einschränkung dieser Kalender und kann von Calendula nicht behoben werden.</string>
<string name="settings_section_notifications">Benachrichtigungen</string>
<string name="settings_reminders">Termin-Erinnerungen</string>
<string name="settings_reminders_hint">Erinnerungen doppelt? Eine andere Kalender-App zeigt sie ebenfalls — schalte sie in einer der beiden ab.</string>
<string name="settings_default_reminder">Standard-Erinnerung</string>
<string name="settings_default_reminder_allday">Ganztägige Termine</string>
<string name="settings_allday_reminder_time">Uhrzeit für ganztägige Erinnerungen</string>
<string name="settings_allday_reminder_time_hint">Erinnerungen für ganztägige Termine werden um %1$s ausgelöst</string>
<string name="reminder_none">Keine</string>
<string name="reminder_use_default">Standard-Erinnerung verwenden</string>
<string name="reminder_custom_amount">Anzahl</string>
<string name="reminder_custom_with_value">Benutzerdefiniert (%1$s)</string>
<string name="reminder_custom_set">Übernehmen</string>
<string name="settings_calendar_reminders_hint">Standard pro Kalender überschreiben — getrennt für Termine mit Uhrzeit und ganztägige Termine. Ein Kalender kann den Standard übernehmen, weglassen oder einen eigenen festlegen.</string>
<string name="settings_calendar_reminder_inherits">Standard (%1$s)</string>
<string name="settings_reliable_delivery">Zuverlässige Zustellung</string>
<string name="settings_reliable_delivery_hint">Android verzögert Erinnerungen womöglich, um Akku zu sparen. Nimm Calendula aus, damit sie pünktlich ankommen.</string>
<string name="settings_reliable_delivery_exempt">Von der Akku-Optimierung ausgenommen — Erinnerungen kommen pünktlich.</string>
<string name="settings_section_calendars">Kalender</string>
<string name="settings_manage_calendars">Kalender verwalten</string>
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
<string name="settings_section_language">Sprache</string>
<string name="settings_language">App-Sprache</string>
<string name="settings_language_auto">Systemstandard</string>
<string name="settings_language_german">Deutsch</string>
<string name="settings_language_english">English</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Design, dynamische Farben, Wochenstart</string>
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
<string name="settings_section_about">Über</string>
<string name="settings_version">Version</string>
<string name="settings_license">Lizenz</string>
<string name="settings_license_value">MIT</string>
<string name="settings_source">Quellcode</string>
<string name="settings_source_open">Öffnen</string>
<string name="settings_about_author">von Jean-Luc Makiola</string>
<string name="settings_about_source">Quellcode</string>
<string name="settings_about_version">Version %1$s</string>
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
<!-- Calendar manager -->
<string name="calendars_title">Kalender</string>
<string name="calendars_local_header">Deine Kalender</string>
<string name="calendars_local_empty">Noch keine lokalen Kalender. Lege einen an, um Termine nur auf diesem Gerät zu speichern.</string>
<string name="calendars_add">Kalender hinzufügen</string>
<string name="calendars_synced_header">Synchronisierte Kalender</string>
<string name="calendars_synced_hint">Diese stammen von Konten auf deinem Gerät. Erstelle und bearbeite sie in der jeweiligen App.</string>
<string name="calendars_manage_in_app">Verwalten</string>
<string name="calendars_add_account">Konto hinzufügen</string>
<string name="calendars_new_title">Neuer Kalender</string>
<string name="calendars_edit_title">Kalender bearbeiten</string>
<string name="calendars_name_label">Name</string>
<string name="calendars_color_label">Farbe</string>
<string name="calendars_description_hint">Beschreibung hinzufügen</string>
<string name="calendars_delete_confirm_title">Kalender löschen?</string>
<string name="calendars_delete_confirm_message">\"%1$s\" und alle zugehörigen Termine werden dauerhaft von diesem Gerät entfernt.</string>
<string name="calendars_write_error">Änderung konnte nicht gespeichert werden.</string>
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="widget_preview_surface">@android:color/system_neutral1_900</color>
<color name="widget_preview_on_surface">@android:color/system_neutral1_50</color>
<color name="widget_preview_variant">@android:color/system_neutral2_200</color>
<color name="widget_preview_primary">@android:color/system_accent1_200</color>
<color name="widget_preview_on_primary">@android:color/system_accent1_800</color>
</resources>

View File

@@ -0,0 +1,6 @@
<resources>
<!-- Dark-scheme window backdrop, matching the Compose dark background/surface
(#101316) so activity recreation (e.g. language switch) doesn't flash a
lighter grey. See values/colors.xml. -->
<color name="window_background">#FF101316</color>
</resources>

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