13 Commits

Author SHA1 Message Date
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
3697a58e5b release: cut v1.0.0 — first public release
Some checks failed
CI / ci (push) Successful in 13m23s
CI / ci (pull_request) Has been cancelled
Build and Release to F-Droid / ci (push) Has been cancelled
Build and Release to F-Droid / build-and-deploy (push) Has been cancelled
Version bumped to 1.0.0 / 7. No code changes beyond the version — 1.0.0 is the
accumulated v0.1 → v0.6 work (all V1 screens, full event read, filter, settings,
onboarding polish) declared release-ready. CHANGELOG [1.0.0] summarises the
shipped feature set; ROADMAP/STATE mark V1 complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:24:47 +02:00
62 changed files with 6259 additions and 392 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

@@ -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
@@ -121,7 +155,7 @@ jobs:
run: chmod +x ./gradlew
- name: Build release APK
run: ./gradlew assembleRelease --no-daemon
run: ./gradlew assembleRelease
- name: Setup F-Droid Server Tools
run: |

View File

@@ -30,7 +30,10 @@ See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
- Home-screen widget
- Full-text search
- Quick-add
- Custom notifications/reminders (system already handles these)
- ~~Custom notifications/reminders (system already handles these)~~ —
**reversed:** Calendula targets sole-calendar-app users, so no other app
posts reminder notifications. We post them ourselves (Etar model). Planned
for v1.4 — see `ROADMAP.md`.
- Tablet/foldable-specific layouts
- iOS support (Android-only by design)

View File

@@ -10,7 +10,7 @@
| v0.4 | Event Detail (S4) + humanized recurrence | complete |
| v0.5 | Calendar filter (M3) + Settings (M4) | complete |
| v0.6 | Full event read — surface every readable field | complete |
| v1.0 | Polish pass, F-Droid release | pending |
| v1.0 | First public release — polish pass, F-Droid | complete |
Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and
Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5.
@@ -44,20 +44,54 @@ Deliberately out of v0.6:
- `CATEGORIES`, `ATTACH` — not reliably exposed by `CalendarContract`
(provider limitation, not our choice)
## v1.0 — First Public Release
## v1.0 — First Public Release — shipped 2026-06-11
All V1 features shipped, polished, on F-Droid. Read-only calendar.
Remaining before v1.0: a UI polish/QA pass.
All V1 features shipped, polished, on F-Droid. Read-only calendar. Cut directly
after v0.6 (full event read) plus the onboarding-screen polish pass.
### Polish backlog (pre-1.0)
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
(Material 3 Expressive onboarding, shipped on the v0.6.0 branch)
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
## v2.0 — Write Support
## v2.0 — Write Support (in progress)
- 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.
| 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 | Quick-add, occurrence edit, conflict dialog, polish, release | planned |
## 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
## v3.0 — Power-User Features

View File

@@ -4,10 +4,13 @@
## Status
**Milestone:** v0.6 — Full event read (complete)
**Phase:** All V1 screens done and the read model is now complete — the detail
view surfaces every readable `CalendarContract` field. Next up is a UI
polish/QA pass before v1.0
**Milestone:** v2.0 — Write support (milestone 2, in progress)
**Phase:** v1.3.0 (edit event) shipped 2026-06-11 after four on-device
review rounds (BYDAY toggles, scoped recurring writes, scope-at-save flip,
stale-instances split bugfix). Milestone 2 runs in four slices
(`docs/superpowers/plans/2026-06-11-03-write-support.md`); v2.0 (quick-add,
conflict dialog, polish) is the remaining slice, v1.4 (reminder
notifications) comes first.
## Progress
@@ -28,7 +31,41 @@ polish/QA pass before v1.0
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
## Next
1. UI polish / QA pass across all views before v1.0
2. F-Droid release of v1.0
1. v1.4 — reminder notifications (essential for sole-app use): `EVENT_REMINDER`
receiver + notification channel, `POST_NOTIFICATIONS`, onboarding step with
default-on toggle + duplicate-reminder warning (Etar model)
2. v2.0 — quick-add sheet, conflict dialog, polish pass, milestone release
3. Monitor the F-Droid build/publish for v1.1.0 v1.3.0

View File

@@ -7,6 +7,166 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
that lives entirely on top of Android's `CalendarContract` — every calendar
synced to the device (CalDAV via DAVx5, Google, local, WebCal, …) shows up
automatically, with zero telemetry and no internet permission.
### Highlights (accumulated across v0.1 → v0.6)
- Month, week, and day views with a view switcher, swipe navigation, and
Loading / Failure / Success states on every screen
- Full-screen event detail surfacing every readable `CalendarContract` field —
times, recurrence (humanised), location, description (with tappable links),
attendees + roles + your own response, reminders, status, availability,
access level, and foreign time zones
- Per-calendar visibility filter (grouped by account, persisted) and a Settings
screen (theme, Material You dynamic colour, week start, app language)
- Material 3 Expressive first-run onboarding for calendar access
- German + English localization throughout
### Changed
- `versionName`/`versionCode` bumped to 1.0.0 / 7
## [0.6.0] — 2026-06-11
### Added

View File

@@ -23,8 +23,8 @@ android {
applicationId = "de.jeanlucmakiola.calendula"
minSdk = 29
targetSdk = 36
versionCode = 6
versionName = "0.6.0"
versionCode = 12
versionName = "1.4.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -3,6 +3,8 @@
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" />
<application
android:name=".CalendulaApp"
@@ -17,13 +19,29 @@
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>
</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>
<!-- 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,5 +1,7 @@
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
@@ -7,7 +9,10 @@ import androidx.activity.enableEdgeToEdge
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
@@ -18,9 +23,16 @@ import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
// 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)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestedDetailKey = intent.detailKeyOrNull()
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 +47,51 @@ class MainActivity : ComponentActivity() {
darkTheme = darkTheme,
dynamicColor = settings.dynamicColor,
) {
RootScreen(modifier = Modifier.fillMaxSize())
RootScreen(
modifier = Modifier.fillMaxSize(),
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = { requestedDetailKey = null },
)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
}
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"
/**
* 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)
}
}
}

View File

@@ -2,18 +2,24 @@ 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.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.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 java.time.ZoneId
import java.time.ZoneOffset
import javax.inject.Inject
import javax.inject.Singleton
@@ -29,6 +35,57 @@ interface CalendarDataSource {
fun calendars(): List<CalendarSource>
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
fun eventDetail(eventId: Long): EventDetail?
/** Insert a new event; returns the new `Events._ID`. */
fun insertEvent(form: EventForm): 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.
*/
fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
/**
* 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`.
*/
fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): 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,
): 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)
}
@@ -74,6 +131,252 @@ class AndroidCalendarDataSource @Inject constructor(
}
}
override fun insertEvent(form: EventForm): 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) }
}
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
val eventId = ContentUris.parseId(uri)
// Best effort (spec §8): the event exists at this point — a reminder
// that fails to attach is logged, not surfaced as a failed create.
form.reminders.distinct().forEach { minutes ->
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
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) {
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.
if (updated.reminders.toSet() != original.reminders.toSet()) {
reconcileReminders(eventId, updated.reminders)
}
}
override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): 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, form.reminders)
return exceptionId
}
override fun updateEventFromOccurrence(
eventId: Long,
beginMillis: Long,
original: EventForm,
updated: EventForm,
): 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)
return eventId
}
// Insert the new series first: if it fails, the original is untouched.
val newEventId = insertEvent(updated)
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]: rows with other
* lead times 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 +422,8 @@ 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"
}
}

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.CalendarSource
internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource(
@@ -10,4 +11,6 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource(
accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty(),
color = getInt(CalendarProjection.IDX_COLOR),
isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0,
canModifyContents = getInt(CalendarProjection.IDX_ACCESS_LEVEL) >=
CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR,
)

View File

@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.CalendarSource
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 +11,46 @@ interface CalendarRepository {
fun calendars(): Flow<List<CalendarSource>>
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
suspend fun eventDetail(eventId: Long): EventDetail
/** 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

@@ -4,6 +4,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
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
@@ -68,6 +69,50 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
}
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
dataSource.insertEvent(form)
}
override suspend fun updateEvent(
eventId: Long,
original: EventForm,
updated: EventForm,
) = withContext(io) {
dataSource.updateEvent(eventId, original, updated)
}
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)
}
override suspend fun updateEventFromOccurrence(
eventId: Long,
beginMillis: Long,
original: EventForm,
updated: EventForm,
): Long = withContext(io) {
dataSource.updateEventFromOccurrence(eventId, beginMillis, original, updated)
}
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

@@ -42,8 +42,9 @@ 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)

View File

@@ -0,0 +1,164 @@
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())
}
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 })
}
/**
* 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,6 +10,7 @@ internal object CalendarProjection {
CalendarContract.Calendars.ACCOUNT_TYPE,
CalendarContract.Calendars.CALENDAR_COLOR,
CalendarContract.Calendars.VISIBLE,
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
)
const val IDX_ID = 0
@@ -18,6 +19,7 @@ internal object CalendarProjection {
const val IDX_ACCOUNT_TYPE = 3
const val IDX_COLOR = 4
const val IDX_VISIBLE = 5
const val IDX_ACCESS_LEVEL = 6
}
internal object InstanceProjection {

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

@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
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,10 +68,65 @@ 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 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 }
}
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 DEFAULT_FORM_FIELDS =
setOf(EventFormField.Location, EventFormField.Description)
}
}

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,121 @@
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 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,
}
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 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)
}
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,12 @@ 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,
)
data class EventInstance(
@@ -109,6 +115,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
@@ -19,6 +20,7 @@ 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
@@ -28,9 +30,18 @@ import kotlinx.datetime.LocalDate
* 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 = {},
) {
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
val onSelectView: (CalendarView) -> Unit = { view = it }
@@ -60,12 +71,39 @@ 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 }
// Event form (v1.2 create) — same held-key pattern as the detail screen:
// [heldCreateIso] keeps the prefill date alive through the slide-out.
var createDateIso by rememberSaveable { mutableStateOf<String?>(null) }
var heldCreateIso by remember { mutableStateOf<String?>(null) }
val onCreateEvent: (LocalDate) -> Unit = { date ->
heldCreateIso = date.toString()
createDateIso = date.toString()
}
// 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) }
val slideSpec = rememberCalendarSlideSpec()
Box(modifier = modifier.fillMaxSize()) {
@@ -75,12 +113,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 +128,7 @@ fun CalendarHost(modifier: Modifier = Modifier) {
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
)
}
@@ -104,6 +145,44 @@ 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,
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
},
)
}
}

View File

@@ -10,14 +10,22 @@ 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 = {},
) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
@@ -40,7 +48,23 @@ 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,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,
modifier = modifier,
)
null -> {}
}
} else {
PermissionScreen(
onGranted = { hasPermission = true },

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

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

@@ -1,11 +1,8 @@
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
@@ -28,12 +25,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 +67,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 +104,7 @@ fun DayScreen(
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onCreateEvent: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
initialDateIso: String? = null,
viewModel: DayViewModel = hiltViewModel(),
@@ -144,7 +141,15 @@ 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()
}
ModalNavigationDrawer(
drawerState = drawerState,
@@ -172,17 +177,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) },
)
}
},
) { innerPadding ->
DayContent(

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,24 +41,32 @@ 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.core.content.ContextCompat
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@@ -63,7 +74,6 @@ 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 +89,14 @@ 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 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,18 +203,29 @@ fun EventDetailScreen(
}
},
actions = {
IconButton(onClick = { /* TODO: edit event (V2) */ }) {
// 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 = { /* TODO: delete event (V2) */ }) {
IconButton(
onClick = onDeleteClick,
enabled = deleteState != DeleteUiState.Deleting,
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete),
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
@@ -159,7 +245,83 @@ 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) {
@@ -580,116 +742,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,47 @@
package de.jeanlucmakiola.calendula.ui.edit
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.EventFormProblem
/**
* 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,
)
/** 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
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,302 @@
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.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.CalendarSource
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.toEditForm
import kotlinx.coroutines.CoroutineDispatcher
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.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 when the event to edit couldn't be loaded; the screen closes itself. */
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
/**
* The event being edited plus the form exactly as it was prefilled.
* For recurring events the write scope is chosen at save time; the
* tapped occurrence's [beginMillis] anchors occurrence-level writes.
*/
private data class EditTarget(
val eventId: Long,
val original: EventForm,
val beginMillis: Long,
)
private data class LocalInputs(
val form: EventForm?,
val saveState: SaveUiState,
val showProblems: Boolean,
val revealed: Set<EventFormField>,
val editTarget: EditTarget?,
)
private data class ExternalInputs(
val writable: List<CalendarSource>,
val lastUsed: Long?,
val defaultFields: Set<EventFormField>,
)
val state: StateFlow<EventEditUiState?> = combine(
combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs),
combine(
repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents } }
.catch { emit(emptyList()) },
prefs.lastUsedCalendarId,
settingsPrefs.defaultFormFields,
::ExternalInputs,
),
) { local, external ->
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,
)
}
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
/**
* Initialise a fresh form for a new event on [date]. 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) {
if (_form.value != null) return
val zone = TimeZone.currentSystemDefault()
val now = Clock.System.now()
val start = if (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)
}
/**
* 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 original = detail.toEditForm(beginMillis, endMillis, TimeZone.currentSystemDefault())
_editTarget.value = EditTarget(eventId, original, beginMillis)
// Sections holding data must show even when not in the defaults.
_revealed.value = original.populatedFields()
_form.value = original
}
}
/** 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
}
/** 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) }
fun setCalendar(id: Long) = update { it.copy(calendarId = id) }
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
fun addReminder(minutes: Int) = update {
it.copy(reminders = (it.reminders + minutes).distinct().sorted())
}
fun removeReminder(minutes: Int) = 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)
}
private fun performSave(form: EventForm, scope: RecurringWriteScope) {
val target = _editTarget.value
viewModelScope.launch {
_saveState.value = SaveUiState.Saving
_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,10 +1,7 @@
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.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -23,13 +20,11 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
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
@@ -62,6 +57,7 @@ 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
@@ -74,8 +70,11 @@ 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 +85,7 @@ fun MonthScreen(
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onOpenSettings: () -> Unit,
onCreateEvent: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
viewModel: MonthViewModel = hiltViewModel(),
) {
@@ -113,8 +113,14 @@ 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()
}
@@ -147,17 +153,20 @@ 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),
)
},
)
}
},
) { innerPadding ->
Column(

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,7 +1,12 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -42,11 +47,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField
/**
* Settings (M4) — appearance (theme, dynamic colour, week start), language,
@@ -111,6 +118,29 @@ fun SettingsScreen(
onSelect = viewModel::setWeekStart,
)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
SectionHeader(stringResource(R.string.settings_section_event_form))
Text(
text = stringResource(R.string.settings_form_fields_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp),
)
EventFormField.entries.forEach { field ->
FormFieldRow(
title = stringResource(formFieldLabel(field)),
checked = field in state.defaultFormFields,
onCheckedChange = { viewModel.setFormFieldDefault(field, it) },
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
SectionHeader(stringResource(R.string.settings_section_notifications))
RemindersRow(
checked = state.remindersEnabled,
onCheckedChange = viewModel::setRemindersEnabled,
)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
SectionHeader(stringResource(R.string.settings_section_language))
LanguageRow()
@@ -232,6 +262,55 @@ private fun DynamicColorRow(
}
}
/**
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
* the pref is set either way; the OS permission is the real gate.
*/
@Composable
private fun RemindersRow(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { /* The pref is already on; a denial just leaves the OS gate shut. */ }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = stringResource(R.string.settings_reminders),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.settings_reminders_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(16.dp))
Switch(
checked = checked,
onCheckedChange = { enabled ->
onCheckedChange(enabled)
val needsPermission = enabled &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
context, Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
if (needsPermission) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
},
)
}
}
@Composable
private fun AboutSection() {
val context = LocalContext.current
@@ -298,6 +377,36 @@ private fun AboutRow(title: String, value: String) {
}
}
@Composable
private fun FormFieldRow(
title: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
private fun formFieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Location -> R.string.event_detail_location
EventFormField.Description -> R.string.event_detail_description
EventFormField.Reminders -> R.string.event_detail_reminders
EventFormField.Recurrence -> R.string.event_detail_recurrence
EventFormField.Availability -> R.string.event_edit_availability
EventFormField.Visibility -> R.string.event_edit_visibility
}
@Composable
private fun themeLabel(mode: ThemeMode): String = stringResource(
when (mode) {

View File

@@ -1,7 +1,9 @@
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.EventFormField
/**
* Settings screen state (M4). Persisted preferences are instant to read, so
@@ -14,4 +16,8 @@ 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,
)

View File

@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
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.EventFormField
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@@ -26,12 +27,16 @@ class SettingsViewModel @Inject constructor(
prefs.themeMode,
prefs.dynamicColor,
prefs.weekStart,
) { theme, dynamic, weekStart ->
prefs.defaultFormFields,
prefs.remindersEnabled,
) { theme, dynamic, weekStart, formFields, reminders ->
SettingsUiState(
themeMode = theme,
dynamicColor = dynamic && dynamicColorAvailable,
dynamicColorAvailable = dynamicColorAvailable,
weekStart = weekStart,
defaultFormFields = formFields,
remindersEnabled = reminders,
)
}.stateIn(
scope = viewModelScope,
@@ -50,4 +55,12 @@ 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) }
}
}

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,11 +1,8 @@
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
@@ -31,12 +28,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 +72,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 +84,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 +112,7 @@ fun WeekScreen(
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onCreateEvent: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(),
) {
@@ -146,7 +146,15 @@ 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()
}
ModalNavigationDrawer(
drawerState = drawerState,
@@ -174,17 +182,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)
},
)
}
},
) { innerPadding ->
WeekContent(

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

@@ -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,58 @@
<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 — 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,6 +159,20 @@
<!-- 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>
@@ -129,6 +195,11 @@
<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_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_section_language">Sprache</string>
<string name="settings_language">App-Sprache</string>
<string name="settings_language_auto">Systemstandard</string>

View File

@@ -14,7 +14,7 @@
<!-- Permission flow (F1) -->
<string name="permission_rationale_title">See all your events, beautifully</string>
<string name="permission_rationale_body">Calendula needs to read your calendar to show your events. That\'s the only thing it ever asks for.</string>
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s the only thing it ever asks for.</string>
<string name="permission_request_button">Grant calendar access</string>
<string name="permission_denied_title">Calendar access denied</string>
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
@@ -26,7 +26,7 @@
<string name="permission_benefit_sync_body">Google, CalDAV, local — anything synced to the device just appears.</string>
<string name="permission_benefit_privacy_title">No tracking, ever</string>
<string name="permission_benefit_privacy_body">Zero telemetry, zero analytics, no ads.</string>
<string name="permission_privacy_footnote">Read-only · no internet permission</string>
<string name="permission_privacy_footnote">Stays on your device · no internet permission</string>
<!-- Month view (S1) -->
<string name="month_prev">Previous month</string>
@@ -48,6 +48,58 @@
<string name="event_detail_back">Back</string>
<string name="event_detail_edit">Edit</string>
<string name="event_detail_delete">Delete</string>
<string name="event_delete_title">Delete event?</string>
<string name="event_delete_body">The event is removed from your calendar and from every device it syncs to.</string>
<string name="event_delete_recurring_title">Delete recurring event</string>
<string name="event_delete_option_occurrence">Only this event</string>
<string name="event_delete_option_following">This and all following events</string>
<string name="event_delete_option_series">All events in the series</string>
<string name="event_edit_recurring_title">Edit recurring event</string>
<string name="event_delete_failed">Couldn\'t delete the event</string>
<string name="event_delete_write_denied">Calendula needs write access to delete events</string>
<string name="dialog_cancel">Cancel</string>
<string name="dialog_ok">OK</string>
<!-- Event form (v1.2 create) -->
<string name="event_edit_new_title">New event</string>
<string name="event_edit_close">Close</string>
<string name="event_edit_save">Save</string>
<string name="event_edit_title_hint">Add title</string>
<string name="event_edit_starts">Starts</string>
<string name="event_edit_ends">Ends</string>
<string name="event_edit_error_end_before_start">Ends before it starts</string>
<string name="event_edit_error_no_calendar">No writable calendar available</string>
<string name="event_edit_save_failed">Couldn\'t save the event</string>
<string name="event_edit_write_denied">Calendula needs write access to create events</string>
<string name="event_edit_more_fields">More fields</string>
<string name="event_edit_add">Add</string>
<string name="event_edit_add_reminder">Add reminder</string>
<string name="event_edit_remove_reminder">Remove reminder</string>
<string name="event_edit_reminder_custom">Custom</string>
<string name="reminder_unit_minutes">minutes</string>
<string name="reminder_unit_hours">hours</string>
<string name="reminder_unit_days">days</string>
<string name="reminder_unit_weeks">weeks</string>
<string name="event_edit_availability">Availability</string>
<string name="event_edit_visibility">Visibility</string>
<!-- Event form — recurrence picker (v1.3) -->
<string name="event_edit_recurrence_none">Does not repeat</string>
<string name="event_edit_recurrence_custom">Custom</string>
<string name="event_edit_recurrence_every">Every</string>
<string name="recurrence_unit_days">days</string>
<string name="recurrence_unit_weeks">weeks</string>
<string name="recurrence_unit_months">months</string>
<string name="recurrence_unit_years">years</string>
<string name="event_edit_recurrence_ends">Ends</string>
<string name="event_edit_recurrence_end_never">Never</string>
<string name="event_edit_recurrence_end_until">On a date</string>
<string name="event_edit_recurrence_end_count">After a number of times</string>
<string name="event_edit_recurrence_times">times</string>
<string name="event_edit_error_recurrence_ends_before_start">Repeats end before the event starts</string>
<string name="event_availability_busy">Busy</string>
<string name="event_access_default">Default</string>
<string name="event_access_public">Public</string>
<string name="event_detail_all_day">All day</string>
<string name="event_detail_calendar">Calendar</string>
<string name="event_detail_calendar_unknown">Unknown calendar</string>
@@ -108,6 +160,20 @@
<!-- Shared event strings -->
<string name="event_untitled">(No title)</string>
<!-- Reminder notifications (v1.4) -->
<string name="reminder_channel_name">Event reminders</string>
<string name="reminder_channel_description">Notifications at the reminder times of your events</string>
<string name="reminder_onboarding_title">Never miss an event</string>
<string name="reminder_onboarding_body">Android doesn\'t show event reminders by itself — a calendar app has to. Let Calendula take that job.</string>
<string name="reminder_benefit_delivery_title">Reminders, delivered</string>
<string name="reminder_benefit_delivery_body">Every reminder on your events arrives as a notification, right on time.</string>
<string name="reminder_benefit_duplicates_title">Using a second calendar app?</string>
<string name="reminder_benefit_duplicates_body">If another app also posts reminders, you\'ll see them twice — turn them off there or here.</string>
<string name="reminder_benefit_reversible_title">Change it anytime</string>
<string name="reminder_benefit_reversible_body">The switch lives in Settings, under Notifications.</string>
<string name="reminder_onboarding_enable_button">Turn on reminders</string>
<string name="reminder_onboarding_skip_button">Not now</string>
<!-- View switcher (M1) -->
<string name="view_month">Month</string>
<string name="view_week">Week</string>
@@ -130,6 +196,11 @@
<string name="settings_week_start_auto">Automatic</string>
<string name="settings_week_start_monday">Monday</string>
<string name="settings_week_start_sunday">Sunday</string>
<string name="settings_section_event_form">New event form</string>
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
<string name="settings_section_notifications">Notifications</string>
<string name="settings_reminders">Event reminders</string>
<string name="settings_reminders_hint">Seeing reminders twice? Another calendar app is posting them too — turn them off in one of the two.</string>
<string name="settings_section_language">Language</string>
<string name="settings_language">App language</string>
<string name="settings_language_auto">System default</string>

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
@@ -12,6 +13,7 @@ class CalendarMapperTest {
accountType: String? = "LOCAL",
color: Int = 0,
visible: Int = 1,
accessLevel: Int = CalendarContract.Calendars.CAL_ACCESS_OWNER,
): MapColumnReader = MapColumnReader(
CalendarProjection.IDX_ID to id,
CalendarProjection.IDX_DISPLAY_NAME to displayName,
@@ -19,6 +21,7 @@ class CalendarMapperTest {
CalendarProjection.IDX_ACCOUNT_TYPE to accountType,
CalendarProjection.IDX_COLOR to color,
CalendarProjection.IDX_VISIBLE to visible,
CalendarProjection.IDX_ACCESS_LEVEL to accessLevel,
)
@Test
@@ -39,6 +42,7 @@ class CalendarMapperTest {
accountType = "com.google",
color = 0xFF112233.toInt(),
isVisibleInSystem = true,
canModifyContents = true,
)
)
}
@@ -65,4 +69,25 @@ class CalendarMapperTest {
assertThat(src.accountName).isEqualTo("")
assertThat(src.accountType).isEqualTo("")
}
@Test
fun `contributor access and above can modify contents`() {
val contributor = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR)
val owner = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_OWNER)
assertThat(contributor.toCalendarSource().canModifyContents).isTrue()
assertThat(owner.toCalendarSource().canModifyContents).isTrue()
}
@Test
fun `read access cannot modify contents`() {
val src = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_READ)
assertThat(src.toCalendarSource().canModifyContents).isFalse()
}
@Test
fun `missing access level defaults to read-only`() {
// WebCal subscriptions on some providers report level 0 (CAL_ACCESS_NONE).
val src = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_NONE)
assertThat(src.toCalendarSource().canModifyContents).isFalse()
}
}

View File

@@ -7,8 +7,12 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.Dispatchers
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
@@ -157,6 +161,172 @@ class CalendarRepositoryImplTest {
}
}
@Test
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
title = "Stand-up",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
)
val id = repo.createEvent(form)
assertThat(id).isEqualTo(77L)
assertThat(fake.insertedForms).containsExactly(form)
}
@Test
fun `createEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
writeError = WriteFailedException("insert event")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
)
try {
repo.createEvent(form)
error("Expected WriteFailedException")
} catch (expected: WriteFailedException) {
assertThat(expected.message).contains("insert")
}
}
@Test
fun `updateEvent forwards id and both forms`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val original = EventForm(
calendarId = 1L,
title = "Stand-up",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
)
val updated = original.copy(title = "Daily")
repo.updateEvent(eventId = 42L, original = original, updated = updated)
assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated))
}
@Test
fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
writeError = WriteFailedException("update event id=42")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
)
try {
repo.updateEvent(eventId = 42L, original = form, updated = form.copy(title = "X"))
error("Expected WriteFailedException")
} catch (expected: WriteFailedException) {
assertThat(expected.message).contains("42")
}
}
@Test
fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
repo.deleteEvent(eventId = 42L)
assertThat(fake.deletedEventIds).containsExactly(42L)
assertThat(fake.deletedOccurrences).isEmpty()
}
@Test
fun `deleteOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
repo.deleteOccurrence(eventId = 42L, beginMillis = 1_000L)
assertThat(fake.deletedOccurrences).containsExactly(42L to 1_000L)
assertThat(fake.deletedEventIds).isEmpty()
}
@Test
fun `deleteEventFromOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
repo.deleteEventFromOccurrence(eventId = 42L, beginMillis = 1_000L)
assertThat(fake.deletedFromOccurrences).containsExactly(42L to 1_000L)
assertThat(fake.deletedEventIds).isEmpty()
assertThat(fake.deletedOccurrences).isEmpty()
}
@Test
fun `updateOccurrence forwards target and form, returns the exception id`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextInsertId = 88L }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
title = "Moved",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
)
val id = repo.updateOccurrence(eventId = 42L, beginMillis = 1_000L, form = form)
assertThat(id).isEqualTo(88L)
assertThat(fake.updatedOccurrences).containsExactly(Triple(42L, 1_000L, form))
assertThat(fake.updatedEvents).isEmpty()
}
@Test
fun `updateEventFromOccurrence forwards target and forms, returns the new id`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextInsertId = 99L }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val original = EventForm(
calendarId = 1L,
title = "Weekly",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
rrule = "FREQ=WEEKLY",
)
val updated = original.copy(title = "Weekly, renamed")
val id = repo.updateEventFromOccurrence(
eventId = 42L,
beginMillis = 1_000L,
original = original,
updated = updated,
)
assertThat(id).isEqualTo(99L)
assertThat(fake.updatedFromOccurrences).containsExactly(Triple(42L, 1_000L, updated))
assertThat(fake.updatedEvents).isEmpty()
}
@Test
fun `deleteEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
writeError = WriteFailedException("delete event id=42")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
try {
repo.deleteEvent(eventId = 42L)
error("Expected WriteFailedException")
} catch (expected: WriteFailedException) {
assertThat(expected.message).contains("42")
}
}
@Test
fun `eventDetail throws NoSuchEventException when data source returns null`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {

View File

@@ -88,6 +88,12 @@ class EventDetailMapperTest {
assertThat(detail.attendees).isEmpty()
}
@Test
fun `missing title stays raw so the edit form does not inherit a placeholder`() {
assertThat(detailReader(title = null).toDetail()!!.instance.title).isEmpty()
assertThat(detailReader(title = "").toDetail()!!.instance.title).isEmpty()
}
@Test
fun `event color falls back to calendar color when null`() {
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())

View File

@@ -0,0 +1,221 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import org.junit.jupiter.api.Test
import java.time.ZoneId
class EventWriteMapperTest {
private val berlin: ZoneId = ZoneId.of("Europe/Berlin")
private fun form(
isAllDay: Boolean = false,
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)),
): EventForm = EventForm(calendarId = 1L, isAllDay = isAllDay, start = start, end = end)
@Test
fun `timed event resolves wall clock in the given zone`() {
val times = form().toWriteTimes(berlin)
// 2026-06-11 is CEST (UTC+2): 10:00 local == 08:00Z.
assertThat(times.dtStartMillis).isEqualTo(1_781_164_800_000L)
assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(90L * 60L * 1000L)
assertThat(times.timezone).isEqualTo("Europe/Berlin")
}
@Test
fun `all-day event lives at UTC midnights with exclusive end`() {
val times = form(isAllDay = true).toWriteTimes(berlin)
assertThat(times.timezone).isEqualTo("UTC")
assertThat(times.dtStartMillis % 86_400_000L).isEqualTo(0L)
// Single-day all-day event: DTEND is the NEXT UTC midnight.
assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(86_400_000L)
}
@Test
fun `availability maps to the provider constants`() {
assertThat(Availability.Busy.toProviderValue())
.isEqualTo(CalendarContract.Events.AVAILABILITY_BUSY)
assertThat(Availability.Free.toProviderValue())
.isEqualTo(CalendarContract.Events.AVAILABILITY_FREE)
assertThat(Availability.Tentative.toProviderValue())
.isEqualTo(CalendarContract.Events.AVAILABILITY_TENTATIVE)
}
@Test
fun `access level maps to the provider constants`() {
assertThat(AccessLevel.Default.toProviderValue())
.isEqualTo(CalendarContract.Events.ACCESS_DEFAULT)
assertThat(AccessLevel.Private.toProviderValue())
.isEqualTo(CalendarContract.Events.ACCESS_PRIVATE)
assertThat(AccessLevel.Confidential.toProviderValue())
.isEqualTo(CalendarContract.Events.ACCESS_CONFIDENTIAL)
assertThat(AccessLevel.Public.toProviderValue())
.isEqualTo(CalendarContract.Events.ACCESS_PUBLIC)
}
@Test
fun `multi-day all-day event spans every covered day`() {
val times = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 13), LocalTime(0, 0)),
).toWriteTimes(berlin)
// 11th, 12th, 13th inclusive = 3 days.
assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(3L * 86_400_000L)
}
@Test
fun `truncation cutoff is the end of the previous local day`() {
// June 9 2026 15:30Z == 17:30 in Berlin. The cutoff must be
// June 8 23:59:59 Berlin == June 8 21:59:59Z.
assertThat(previousLocalDayEndUtcMillis(1_781_019_000_000L, berlin))
.isEqualTo(1_780_955_999_000L)
// All-day series live in UTC: the cutoff for a June 9 UTC-midnight
// occurrence is June 8 23:59:59Z.
assertThat(previousLocalDayEndUtcMillis(1_780_963_200_000L, ZoneId.of("UTC")))
.isEqualTo(1_780_963_199_000L)
}
@Test
fun `duration renders seconds for timed and days for all-day events`() {
assertThat(form().toWriteTimes(berlin).toRfc2445Duration(isAllDay = false))
.isEqualTo("P5400S")
assertThat(form(isAllDay = true).toWriteTimes(berlin).toRfc2445Duration(isAllDay = true))
.isEqualTo("P1D")
}
// --- buildEventUpdateValues (dirty-checked partial update) ---
private val seriesStart = 1_700_000_000_000L
private fun update(original: EventForm, updated: EventForm): Map<String, Any?> =
buildEventUpdateValues(original, updated, seriesStart, berlin)
@Test
fun `pristine form produces no values`() {
val original = form()
assertThat(update(original, original.copy())).isEmpty()
}
@Test
fun `text-only edit writes just the changed columns`() {
val original = form()
val values = update(original, original.copy(title = "New", description = "Body"))
assertThat(values).containsExactly(
CalendarContract.Events.TITLE, "New",
CalendarContract.Events.DESCRIPTION, "Body",
)
}
@Test
fun `clearing location writes an explicit null`() {
val original = form().copy(location = "Berlin")
val values = update(original, original.copy(location = " "))
assertThat(values).containsExactly(CalendarContract.Events.EVENT_LOCATION, null)
}
@Test
fun `time edit on a one-off event writes absolute times and clears recurrence columns`() {
val original = form()
val updated = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)),
)
val values = update(original, updated)
// 2026-06-11 11:00 CEST == 09:00Z.
assertThat(values[CalendarContract.Events.DTSTART])
.isEqualTo(1_781_164_800_000L + 3_600_000L)
assertThat(values[CalendarContract.Events.DTEND])
.isEqualTo(1_781_164_800_000L + 2L * 3_600_000L)
assertThat(values).containsEntry(CalendarContract.Events.RRULE, null)
assertThat(values).containsEntry(CalendarContract.Events.DURATION, null)
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(0)
}
@Test
fun `time edit on a recurring event moves the series start by the same delta`() {
val original = form().copy(rrule = "FREQ=WEEKLY")
val updated = original.copy(
// Pushed one hour later than the displayed occurrence.
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
)
val values = update(original, updated)
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(seriesStart + 3_600_000L)
assertThat(values).containsEntry(CalendarContract.Events.DTEND, null)
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
}
@Test
fun `adding a recurrence keeps the times and writes rule plus duration`() {
val original = form()
val values = update(original, original.copy(rrule = "FREQ=DAILY;COUNT=5"))
// The event was one-off, so the row's DTSTART is the occurrence start
// and a zero delta keeps it in place.
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(seriesStart)
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY;COUNT=5")
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
assertThat(values).containsEntry(CalendarContract.Events.DTEND, null)
}
@Test
fun `removing the recurrence writes absolute occurrence times and clears the rule`() {
val original = form().copy(rrule = "FREQ=WEEKLY")
val values = update(original, original.copy(rrule = null))
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
assertThat(values[CalendarContract.Events.DTEND])
.isEqualTo(1_781_164_800_000L + 5_400_000L)
assertThat(values).containsEntry(CalendarContract.Events.RRULE, null)
assertThat(values).containsEntry(CalendarContract.Events.DURATION, null)
}
@Test
fun `reminder-only changes touch no event columns`() {
val original = form()
assertThat(update(original, original.copy(reminders = listOf(10)))).isEmpty()
}
// --- buildOccurrenceExceptionValues ("edit only this event") ---
@Test
fun `occurrence exception carries absolute times and the original instance`() {
val values = buildOccurrenceExceptionValues(
form = form().copy(title = "Moved", location = "Berlin"),
originalInstanceMillis = 1_700_000_000_000L,
zone = berlin,
)
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
.isEqualTo(1_700_000_000_000L)
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin")
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
assertThat(values[CalendarContract.Events.DTEND])
.isEqualTo(1_781_164_800_000L + 5_400_000L)
// A single occurrence never carries its own rule.
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
}
@Test
fun `occurrence exception clears empty optionals explicitly`() {
// The provider clones the parent row, so a blank field must be an
// explicit NULL or the parent's value would survive.
val values = buildOccurrenceExceptionValues(
form = form(),
originalInstanceMillis = 0L,
zone = berlin,
)
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
}
}

View File

@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
/**
@@ -13,6 +14,18 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var calendarsResult: List<CalendarSource> = emptyList()
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null }
/** Set to make the next write call throw. */
var writeError: Exception? = null
/** Id returned by the next [insertEvent]. */
var nextInsertId: Long = 100L
val insertedForms = mutableListOf<EventForm>()
val updatedEvents = mutableListOf<Triple<Long, EventForm, EventForm>>()
val updatedOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
val updatedFromOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
val deletedEventIds = mutableListOf<Long>()
val deletedOccurrences = mutableListOf<Pair<Long, Long>>()
val deletedFromOccurrences = mutableListOf<Pair<Long, Long>>()
private val listeners = mutableListOf<() -> Unit>()
@@ -20,6 +33,49 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
instancesResult(beginMillis, endMillis)
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
override fun insertEvent(form: EventForm): Long {
writeError?.let { throw it }
insertedForms += form
return nextInsertId
}
override fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) {
writeError?.let { throw it }
updatedEvents += Triple(eventId, original, updated)
}
override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long {
writeError?.let { throw it }
updatedOccurrences += Triple(eventId, beginMillis, form)
return nextInsertId
}
override fun updateEventFromOccurrence(
eventId: Long,
beginMillis: Long,
original: EventForm,
updated: EventForm,
): Long {
writeError?.let { throw it }
updatedFromOccurrences += Triple(eventId, beginMillis, updated)
return nextInsertId
}
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
writeError?.let { throw it }
deletedFromOccurrences += eventId to beginMillis
}
override fun deleteEvent(eventId: Long) {
writeError?.let { throw it }
deletedEventIds += eventId
}
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
writeError?.let { throw it }
deletedOccurrences += eventId to beginMillis
}
override fun registerChangeListener(listener: () -> Unit) {
listeners += listener
}

View File

@@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventFormField
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.DayOfWeek
@@ -60,6 +61,66 @@ class SettingsPrefsTest {
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
}
@Test
fun `form fields default to location and description`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.defaultFormFields.first()).containsExactly(
EventFormField.Location,
EventFormField.Description,
)
}
@Test
fun `form field toggle round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setFormFieldDefault(EventFormField.Reminders, enabled = true)
prefs.setFormFieldDefault(EventFormField.Location, enabled = false)
assertThat(prefs.defaultFormFields.first()).containsExactly(
EventFormField.Description,
EventFormField.Reminders,
)
}
@Test
fun `disabling every form field persists as none, not factory default`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
EventFormField.entries.forEach { prefs.setFormFieldDefault(it, enabled = false) }
assertThat(prefs.defaultFormFields.first()).isEmpty()
}
@Test
fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
m[SettingsPrefs.FORM_FIELDS_KEY] = "Location,Hologram"
m
}
assertThat(prefs.defaultFormFields.first()).containsExactly(EventFormField.Location)
}
@Test
fun `reminders default to enabled, onboarding to not done`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.remindersEnabled.first()).isTrue()
assertThat(prefs.reminderOnboardingDone.first()).isFalse()
}
@Test
fun `reminders toggle round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setRemindersEnabled(false)
assertThat(prefs.remindersEnabled.first()).isFalse()
}
@Test
fun `reminder onboarding completes one-way`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setReminderOnboardingDone()
assertThat(prefs.reminderOnboardingDone.first()).isTrue()
}
@Test
fun `explicit week-start prefs resolve regardless of locale`() {
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)

View File

@@ -0,0 +1,85 @@
package de.jeanlucmakiola.calendula.data.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.Locale
class ReminderTimeTextTest {
private val berlin = ZoneId.of("Europe/Berlin")
private fun millisAt(dateTime: LocalDateTime, zone: ZoneId): Long =
dateTime.atZone(zone).toInstant().toEpochMilli()
private fun utcMidnight(date: LocalDate): Long =
date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
@Test
fun `timed event on one day shows just the time range`() {
val text = reminderTimeText(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin),
isAllDay = false,
zone = berlin,
locale = Locale.GERMANY,
)
assertThat(text).isEqualTo("09:30 10:00")
}
@Test
fun `timed event crossing midnight includes both dates`() {
val text = reminderTimeText(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin),
isAllDay = false,
zone = berlin,
locale = Locale.GERMANY,
)
assertThat(text).contains("11.06.2026")
assertThat(text).contains("12.06.2026")
assertThat(text).contains("23:30")
}
@Test
fun `all-day single day shows one date, read in UTC`() {
val text = reminderTimeText(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
isAllDay = true,
// Zone must not matter for all-day events: UTC midnight is
// 02:00 in Berlin — naive local reading would shift the day.
zone = berlin,
locale = Locale.GERMANY,
)
assertThat(text).isEqualTo("11.06.2026")
}
@Test
fun `all-day multi-day shows the last covered day, not the exclusive end`() {
val text = reminderTimeText(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
isAllDay = true,
zone = berlin,
locale = Locale.GERMANY,
)
assertThat(text).isEqualTo("11.06.2026 12.06.2026")
}
@Test
fun `degenerate all-day range never renders an inverted span`() {
val day = utcMidnight(LocalDate.of(2026, 6, 11))
val text = reminderTimeText(
beginMillis = day,
endMillis = day,
isAllDay = true,
zone = berlin,
locale = Locale.GERMANY,
)
assertThat(text).isEqualTo("11.06.2026")
}
}

View File

@@ -0,0 +1,202 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class EventFormTest {
private fun form(
calendarId: Long? = 1L,
isAllDay: Boolean = false,
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
): EventForm = EventForm(calendarId = calendarId, isAllDay = isAllDay, start = start, end = end)
@Test
fun `valid timed form has no problems`() {
assertThat(form().problems()).isEmpty()
}
@Test
fun `missing calendar is a problem`() {
assertThat(form(calendarId = null).problems())
.containsExactly(EventFormProblem.NoCalendar)
}
@Test
fun `timed end before start is a problem`() {
val bad = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)))
assertThat(bad.problems()).containsExactly(EventFormProblem.EndBeforeStart)
}
@Test
fun `zero-length timed event is allowed`() {
val instant = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
assertThat(instant.problems()).isEmpty()
}
@Test
fun `all-day single day is allowed even though times match`() {
val allDay = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
)
assertThat(allDay.problems()).isEmpty()
}
@Test
fun `all-day end date before start date is a problem`() {
val bad = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(0, 0)),
)
assertThat(bad.problems()).containsExactly(EventFormProblem.EndBeforeStart)
}
@Test
fun `problems accumulate`() {
val bad = form(
calendarId = null,
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)),
)
assertThat(bad.problems()).containsExactly(
EventFormProblem.NoCalendar,
EventFormProblem.EndBeforeStart,
)
}
@Test
fun `recurrence until before the first day is a problem`() {
// Days before the start, so it parses to an earlier date in any zone.
val bad = form().copy(rrule = "FREQ=DAILY;UNTIL=20260601T120000Z")
assertThat(bad.problems())
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
}
@Test
fun `recurrence until on or after the first day is fine`() {
// Date-only UNTIL parses zone-independently.
assertThat(form().copy(rrule = "FREQ=DAILY;UNTIL=20260611").problems()).isEmpty()
assertThat(form().copy(rrule = "FREQ=WEEKLY").problems()).isEmpty()
}
@Test
fun `complex rrules are not validated against the start`() {
// The picker can't have produced this ("second Monday" ordinal BYDAY);
// it is preserved verbatim and never flagged.
assertThat(form().copy(rrule = "FREQ=MONTHLY;BYDAY=2MO;UNTIL=20200101").problems()).isEmpty()
}
@Test
fun `weekly byday rules are validated against the start`() {
val bad = form().copy(rrule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20200101")
assertThat(bad.problems())
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
}
private val berlin = TimeZone.of("Europe/Berlin")
private fun detail(
isAllDay: Boolean = false,
title: String = "Stand-up",
location: String? = "Berlin",
description: String? = "Body",
rrule: String? = null,
reminders: List<Reminder> = emptyList(),
availability: Availability = Availability.Busy,
accessLevel: AccessLevel = AccessLevel.Default,
): EventDetail = EventDetail(
instance = EventInstance(
instanceId = 1L,
eventId = 1L,
calendarId = 7L,
title = title,
start = Instant.fromEpochMilliseconds(0L),
end = Instant.fromEpochMilliseconds(0L),
isAllDay = isAllDay,
color = 0,
location = location,
),
description = description,
organizer = null,
attendees = emptyList(),
rrule = rrule,
reminders = reminders,
availability = availability,
accessLevel = accessLevel,
)
@Test
fun `toEditForm prefills a timed event from the occurrence times`() {
// 2026-06-11 is CEST (UTC+2): 08:00Z == 10:00 local.
val prefilled = detail().toEditForm(
beginMillis = 1_781_164_800_000L,
endMillis = 1_781_164_800_000L + 90L * 60L * 1000L,
zone = berlin,
)
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
assertThat(prefilled.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)))
assertThat(prefilled.isAllDay).isFalse()
assertThat(prefilled.calendarId).isEqualTo(7L)
assertThat(prefilled.title).isEqualTo("Stand-up")
assertThat(prefilled.location).isEqualTo("Berlin")
assertThat(prefilled.description).isEqualTo("Body")
}
@Test
fun `toEditForm turns the exclusive all-day end into the last covered day`() {
// 11th..13th = UTC midnights of the 11th and the (exclusive) 14th.
val prefilled = detail(isAllDay = true).toEditForm(
beginMillis = LocalDate(2026, 6, 11).toEpochDays() * 86_400_000L,
endMillis = LocalDate(2026, 6, 14).toEpochDays() * 86_400_000L,
zone = berlin,
)
assertThat(prefilled.start.date).isEqualTo(LocalDate(2026, 6, 11))
assertThat(prefilled.end.date).isEqualTo(LocalDate(2026, 6, 13))
assertThat(prefilled.isAllDay).isTrue()
}
@Test
fun `toEditForm sorts and dedupes reminder minutes and strips an RRULE prefix`() {
val prefilled = detail(
rrule = "RRULE:FREQ=WEEKLY",
reminders = listOf(
Reminder(30, ReminderMethod.Email),
Reminder(10, ReminderMethod.Alert),
Reminder(30, ReminderMethod.Alert),
),
).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
assertThat(prefilled.reminders).containsExactly(10, 30).inOrder()
assertThat(prefilled.rrule).isEqualTo("FREQ=WEEKLY")
}
@Test
fun `populatedFields reports exactly the sections holding values`() {
val empty = form().copy(location = "", description = "")
assertThat(empty.populatedFields()).isEmpty()
val full = form().copy(
location = "Berlin",
description = "Body",
reminders = listOf(10),
rrule = "FREQ=DAILY",
availability = Availability.Free,
accessLevel = AccessLevel.Private,
)
assertThat(full.populatedFields()).containsExactly(
EventFormField.Location,
EventFormField.Description,
EventFormField.Reminders,
EventFormField.Recurrence,
EventFormField.Availability,
EventFormField.Visibility,
)
}
}

View File

@@ -0,0 +1,164 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
class RecurrenceTest {
private val utc = TimeZone.UTC
private val berlin = TimeZone.of("Europe/Berlin")
@Test
fun `plain frequency parses with defaults`() {
assertThat(parseSimpleRecurrence("FREQ=WEEKLY"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Weekly))
assertThat(parseSimpleRecurrence("FREQ=DAILY"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Daily))
}
@Test
fun `leading RRULE prefix and WKST are tolerated`() {
assertThat(parseSimpleRecurrence("RRULE:FREQ=MONTHLY;WKST=MO"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Monthly))
}
@Test
fun `interval parses`() {
assertThat(parseSimpleRecurrence("FREQ=WEEKLY;INTERVAL=2"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Weekly, interval = 2))
}
@Test
fun `until parses date-only and UTC datetime forms`() {
val expected = SimpleRecurrence(
RecurrenceFreq.Daily,
end = RecurrenceEnd.Until(LocalDate(2026, 8, 1)),
)
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801", utc)).isEqualTo(expected)
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801T235959Z", utc))
.isEqualTo(expected)
}
@Test
fun `until datetime converts from UTC into the given zone before taking the date`() {
// 21:59:59Z == 23:59:59 in Berlin (CEST) — still August 1 there.
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801T215959Z", berlin))
.isEqualTo(
SimpleRecurrence(RecurrenceFreq.Daily, end = RecurrenceEnd.Until(LocalDate(2026, 8, 1))),
)
}
@Test
fun `count parses`() {
assertThat(parseSimpleRecurrence("FREQ=YEARLY;COUNT=5"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Yearly, end = RecurrenceEnd.Count(5)))
}
@Test
fun `weekly byday parses as weekday picks`() {
assertThat(parseSimpleRecurrence("FREQ=WEEKLY;BYDAY=MO,FR"))
.isEqualTo(
SimpleRecurrence(
RecurrenceFreq.Weekly,
byDays = setOf(DayOfWeek.MONDAY, DayOfWeek.FRIDAY),
),
)
}
@Test
fun `rules beyond the simple shape are rejected`() {
// Ordinal BYDAY ("second Thursday") and BYDAY on non-weekly rules.
assertThat(parseSimpleRecurrence("FREQ=WEEKLY;BYDAY=MO,2TH")).isNull()
assertThat(parseSimpleRecurrence("FREQ=MONTHLY;BYDAY=MO")).isNull()
assertThat(parseSimpleRecurrence("FREQ=MONTHLY;BYMONTHDAY=15")).isNull()
assertThat(parseSimpleRecurrence("FREQ=HOURLY")).isNull()
assertThat(parseSimpleRecurrence("")).isNull()
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801;COUNT=3")).isNull()
assertThat(parseSimpleRecurrence("FREQ=DAILY;INTERVAL=0")).isNull()
assertThat(parseSimpleRecurrence("FREQ=DAILY;COUNT=abc")).isNull()
}
@Test
fun `toRRule renders the minimal form`() {
assertThat(SimpleRecurrence(RecurrenceFreq.Weekly).toRRule()).isEqualTo("FREQ=WEEKLY")
assertThat(SimpleRecurrence(RecurrenceFreq.Daily, interval = 3).toRRule())
.isEqualTo("FREQ=DAILY;INTERVAL=3")
assertThat(
SimpleRecurrence(RecurrenceFreq.Monthly, end = RecurrenceEnd.Count(12)).toRRule(),
).isEqualTo("FREQ=MONTHLY;COUNT=12")
}
@Test
fun `toRRule renders weekdays in ISO order regardless of set order`() {
val rule = SimpleRecurrence(
RecurrenceFreq.Weekly,
byDays = setOf(DayOfWeek.FRIDAY, DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY),
).toRRule()
assertThat(rule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE,FR")
}
@Test
fun `toRRule ignores weekday picks on non-weekly frequencies`() {
val rule = SimpleRecurrence(
RecurrenceFreq.Monthly,
byDays = setOf(DayOfWeek.MONDAY),
).toRRule()
assertThat(rule).isEqualTo("FREQ=MONTHLY")
}
@Test
fun `toRRule writes until as the end of the chosen day in the given zone`() {
val rule = SimpleRecurrence(
RecurrenceFreq.Weekly,
interval = 2,
end = RecurrenceEnd.Until(LocalDate(2026, 8, 1)),
)
assertThat(rule.toRRule(utc))
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;UNTIL=20260801T235959Z")
// 23:59:59 Berlin (CEST, +2) == 21:59:59Z.
assertThat(rule.toRRule(berlin))
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;UNTIL=20260801T215959Z")
}
// 2026-06-19T23:59:00Z — a moment just before a June 20 occurrence.
private val cutoffMillis = 1_781_913_540_000L
@Test
fun `truncation replaces count and keeps every other part`() {
assertThat(rruleTruncatedAt("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;COUNT=30", cutoffMillis))
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;UNTIL=20260619T235900Z")
}
@Test
fun `truncation replaces an existing until`() {
assertThat(rruleTruncatedAt("FREQ=DAILY;UNTIL=20301231T235959Z", cutoffMillis))
.isEqualTo("FREQ=DAILY;UNTIL=20260619T235900Z")
}
@Test
fun `truncation works on rules the simple picker cannot express`() {
assertThat(rruleTruncatedAt("RRULE:FREQ=MONTHLY;BYDAY=2TH", cutoffMillis))
.isEqualTo("FREQ=MONTHLY;BYDAY=2TH;UNTIL=20260619T235900Z")
}
@Test
fun `parse and render round-trip`() {
val rules = listOf(
"FREQ=DAILY",
"FREQ=WEEKLY;INTERVAL=2",
"FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;UNTIL=20301231T235959Z",
"FREQ=MONTHLY;COUNT=6",
"FREQ=YEARLY;UNTIL=20301231T235959Z",
)
rules.forEach { rule ->
assertThat(parseSimpleRecurrence(rule, utc)!!.toRRule(utc)).isEqualTo(rule)
}
// Round-trips through a non-UTC zone too: 22:59:59Z == 23:59:59 CET.
val berlinRule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20301231T225959Z"
assertThat(parseSimpleRecurrence(berlinRule, berlin)!!.toRRule(berlin))
.isEqualTo(berlinRule)
}
}

View File

@@ -0,0 +1,188 @@
# Calendula - Plan 03: Write Support (Milestone 2 / v2.0)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Calendula kann Events anlegen, bearbeiten und löschen — direkt über
`CalendarContract`-Writes, ohne eigene DB. Der V1-Spec dient als Leitplanke,
nicht als Gesetz: Ausgeliefert wird in vier Slices (v1.1 → v2.0), jeder Slice
ist für sich releasebar und lässt `./gradlew lint test assembleDebug` grün.
**Architecture:** Writes laufen durch dieselbe Schichtung wie Reads:
`ui/``CalendarRepository` (Interface) → `CalendarDataSource`
`ContentResolver.insert/update/delete`. Kein neuer Layer, keine Transaktions-
Abstraktion — der Provider notified nach jedem Write selbst, der bestehende
`ContentObserver`-Tick aktualisiert alle Views automatisch (F3 gilt unverändert).
Domain bleibt pure Kotlin.
**Leitentscheidungen (Abweichungen / Präzisierungen ggü. Spec §2 "V2"):**
1. **Permission-Strategie:** `WRITE_CALENDAR` kommt ins Manifest. Das Onboarding
fragt READ+WRITE zusammen an (eine System-Dialog-Gruppe), zwingend bleibt
nur READ — wer Write ablehnt, nutzt die App weiter read-only.
v1.0-Upgrader (haben nur READ) bekommen den WRITE-Request kontextuell beim
ersten Schreib-Versuch. Onboarding-Footnote verliert die "Nur Lesezugriff"-
Behauptung (wäre mit Manifest-Eintrag gelogen).
2. **Read-only-Kalender respektieren:** `Calendars.CALENDAR_ACCESS_LEVEL` wird
mitgelesen (`canModifyContents` = Level ≥ `CAL_ACCESS_CONTRIBUTOR`).
Edit/Delete-Actions erscheinen gar nicht erst für WebCal-Subscriptions,
Geburtstags- und andere read-only-Kalender.
3. **Recurring Events:** Löschen bietet "Nur dieser Termin" (Exception-Insert
via `Events.CONTENT_EXCEPTION_URI` mit `STATUS_CANCELED` +
`ORIGINAL_INSTANCE_TIME`) vs. "Ganze Serie" (Delete der Events-Row).
Bearbeiten startet mit "ganze Serie"; Occurrence-Edit (Exception mit neuen
Werten) folgt erst, wenn das Serien-Edit stabil ist.
4. **Kein RRULE-Editor in v1.2:** Create startet ohne Wiederholungs-UI
(einmalige Events). Ein einfacher Recurrence-Picker (täglich/wöchentlich/
monatlich/jährlich + Ende) kommt mit v1.3/v2.0.
5. **Conflict UX (Spec V2 "event modified externally during edit"):** kein
Locking. Beim Speichern wird gegen die beim Laden gemerkte Row verglichen
(Dirty-Check auf den editierten Feldern); bei externem Konflikt Dialog
"Überschreiben / Verwerfen". Mehr ist YAGNI.
---
## Slices
| Slice | Inhalt | Status |
|---|---|---|
| v1.1 | Write-Fundament: `WRITE_CALENDAR`, `canModifyContents`, Delete (Serie + einzelnes Vorkommen) | ausgeliefert (v1.1.0, 2026-06-11) |
| v1.2 | Create: Event-Formular (Titel, Kalender, ganztägig, Start/Ende, Ort, Beschreibung), FAB, Default-Kalender-Pref | ausgeliefert (v1.2.0, 2026-06-11) |
| v1.3 | Edit: Formular wiederverwendet, Serien-Edit, Reminder-Edit, einfacher Recurrence-Picker | implementiert (Release wartet auf On-Device-Review) |
| v2.0 | Quick-Add, Occurrence-Edit, Konflikt-Dialog, Polish-Pass, Release | offen |
## v1.1 — Write-Fundament + Delete
**Build/Manifest:**
- [x] `AndroidManifest.xml`: `WRITE_CALENDAR` ergänzen
**Data layer:**
- [x] `Projections.kt`: `CALENDAR_ACCESS_LEVEL` in `CalendarProjection`
- [x] `Models.kt`: `CalendarSource.canModifyContents: Boolean` (Default `false`).
Kein neuer `FailureReason` — Delete-Fehler sind ein Snackbar-Fall, kein
Full-Screen-Failure
- [x] `CalendarMapper.kt`: Access-Level → `canModifyContents`
- [x] `CalendarDataSource`: `deleteEvent(eventId)`, `deleteOccurrence(eventId, beginMillis)`
— Impl in `AndroidCalendarDataSource` (`delete` auf Events-URI bzw.
Exception-Insert), `WriteFailedException` bei 0 rows / null-Uri
- [x] `CalendarRepository(+Impl)`: beide Methoden durchreichen, auf `io`
**UI:**
- [x] `EventDetailUiState.Success.canModify` (Kalender-Lookup im ViewModel)
- [x] `EventDetailViewModel`: `delete(mode)` mit eigenem One-Shot-State
(Idle/Deleting/Deleted/Failed); `SecurityException` → kontextueller
WRITE-Request statt Failure-Screen
- [x] `EventDetailScreen`: Edit/Delete nur wenn `canModify`; Delete →
Confirm-Dialog (recurring: "Nur dieser Termin" / "Ganze Serie"),
Erfolg → zurück, Fehler → Snackbar
- [x] Onboarding (`PermissionScreen`): `RequestMultiplePermissions` READ+WRITE,
Gate bleibt READ; Copy-Anpassung (Footnote, Rationale-Body) DE+EN
**Tests:**
- [x] `FakeCalendarDataSource`: Write-Ops aufnehmen
- [x] `CalendarRepositoryImplTest`: delete-Pfade (Erfolg, Fehler)
- [x] `CalendarMapperTest`: Access-Level-Mapping
## v1.2 — Create
- [x] `EventForm`-Domain-Modell + Validierung (`problems()`: EndBeforeStart,
NoCalendar; leerer Titel und Instant-Events erlaubt)
- [x] `EventEditScreen` (ein Formular, ab v1.3 auch für Edit), M3-Date/Time-Picker
- [x] FAB-Stack auf allen drei Hauptansichten (`CalendarFabColumn`: "+" immer,
Heute-Pill darüber), vorbelegt mit dem sichtbaren Tag
- [x] Kalender-Vorauswahl: explizit > zuletzt benutzt
(`CalendarPrefs.lastUsedCalendarId` statt Settings-Eintrag) > erster
beschreibbarer; Picker bietet nur beschreibbare Kalender an
- [x] `insertEvent(form): Long` im DataSource; `EventWriteMapper` (JVM-testbar)
normalisiert all-day auf UTC-Mitternächte mit exklusivem DTEND
## v1.3 — Edit
**Domain:**
- [x] `EventForm.rrule` (roher RRULE-Wert, null = einmalig); komplexe Regeln
(ordinales BYDAY wie "2TH", BYMONTHDAY etc.) bleiben verbatim erhalten,
solange der Picker sie nicht ersetzt
- [x] `SimpleRecurrence` (FREQ + INTERVAL + UNTIL/COUNT + wöchentliches
BYDAY — Review-Feedback: "jede Woche Mo+Fr" muss gehen) mit
`parseSimpleRecurrence`/`toRRule` (Recurrence.kt, JVM-getestet)
- [x] `EventDetail.toEditForm(begin, end, zone)` — Prefill inkl. all-day-
Rückrechnung (exklusives DTEND → letzter abgedeckter Tag)
- [x] Validierung: `RecurrenceEndsBeforeStart` (UNTIL vor erstem Tag hieße
null Vorkommen — Event würde unsichtbar)
**Data layer:**
- [x] `buildEventUpdateValues(original, updated, seriesDtStart, zone)`
Dirty-Check, nur geänderte Spalten; Zeitfelder als Einheit:
einmalig → DTSTART/DTEND (RRULE/DURATION genullt), wiederkehrend →
Serien-DTSTART verschiebt sich um das User-Delta, DURATION statt DTEND
- [x] `CalendarDataSource.updateEvent(eventId, original, updated)` — Events-Row-
Update + Reminder-Diff nach Minuten (unberührte Rows behalten ihre Methode)
- [x] `insertEvent` versteht RRULE (RRULE+DURATION statt DTEND — Provider-Invariante)
- [x] `CalendarRepository(+Impl).updateEvent` durchgereicht, auf `io`
- [x] `EventDetailMapper`: Titel bleibt roh (kein "(Ohne Titel)"-Fallback mehr —
der Detail-Screen ersetzt selbst lokalisiert, das Formular braucht den Rohwert)
**UI:**
- [x] `EventEditViewModel.openForEdit` (lädt Detail, merkt Original für
Dirty-Check; unverändertes Formular speichert als No-Op); Felder mit
Werten werden unabhängig vom Settings-Default eingeblendet
- [x] `EventEditScreen`: `editKey`-Parameter, Kalender im Edit-Modus fixiert,
Repeat-Karte + `RecurrencePickerDialog` (Presets per Tap, Custom-Schritt
mit Intervall/Einheit + Wochentags-Toggles bei "Wochen" (Wochenstart
nach Locale, Start-Wochentag vorausgewählt) + Ende nie/Datum/Anzahl,
OptionCard-Stil)
- [x] Recurrence-Humanizer nach `ui/common/RecurrenceText.kt` (Detail + Formular)
- [x] `EventDetailScreen`: Edit-Action (nur `canModify`, kontextueller
WRITE-Request wie Delete); Save schließt Formular **und** Detail (die
getappte Occurrence existiert danach evtl. nicht mehr)
- [x] **Occurrence-Edit (aus v2.0 vorgezogen, Review-Feedback):** Die
Scope-Frage kommt **beim Speichern** (Google-Modell, Review-Feedback):
ein dirty wiederkehrender Termin parkt in `SaveUiState.AwaitingScope`,
der Dialog bietet "Nur dieser Termin / Dieser und alle folgenden /
Ganze Serie"; bei geänderter Wiederholungsregel entfällt "nur dieser"
(eine Exception-Row trägt keine eigene Regel). "Nur dieser" schreibt
eine Modified-Occurrence-Exception (`CONTENT_EXCEPTION_URI`, alle
Formularwerte, leere Optionals als explizite NULLs weil der Provider
die Serien-Row klont), Reminder werden gegen die tatsächlichen
Provider-Rows abgeglichen. "Dieser und folgende" = Serien-Split:
neues Event mit den Formularwerten (insert zuerst — schlägt es fehl,
bleibt das Original unberührt), dann Original-RRULE per UNTIL gekappt;
ab der ersten Occurrence = normales Serien-Update. Ein mitgenommenes
COUNT zählt in der neuen Serie neu (kein Rest-COUNT-Rechnen wie AOSP)
- [x] **Delete dreistufig (Review-Feedback):** "Nur dieser Termin" /
"Dieser und alle folgenden" (RRULE-Truncation via `rruleTruncatedAt`)
/ "Alle Termine der Serie"; ab der ersten Occurrence = ganze Serie
löschen
- [x] **Split-Duplikat-Bugfix (On-Device-Review):** Nach dem Serien-Split
blieb die getappte Occurrence doppelt sichtbar. Root cause (per
adb-Probe verifiziert): der Provider regeneriert die Instances eines
Events nur aus den **Values des Updates selbst** — ein RRULE-only-
Update lässt die alten Instances stehen, und ein Teilset (nur DTSTART)
erzeugt kaputte Nulllängen-Instanzen. Truncation-Updates schicken
deshalb das komplette Zeit-Set (DTSTART/DURATION/RRULE/ALL_DAY/
EVENT_TIMEZONE) zusammen (`truncateSeries`), wie AOSPs
EditEventHelper. Zusätzlich (Robustheit, Google-Modell): Cutoff =
Ende des Vortags in der Event-Zeitzone (`previousLocalDayEndUtcMillis`)
statt Occurrence1s, und der Recurrence-Picker rendert UNTIL als
lokales Tagesende in UTC (`toRRule(zone)`) statt pauschal `T235959Z`
(sonst kann bei UTC+x ein Extra-Tag hineinrutschen)
- [x] `CalendarHost`: Edit-Overlay mit Held-Key-Pattern
- [x] `EventFormField.Recurrence` (Formular, "Mehr Felder", Settings-Default)
- [x] Strings DE+EN
**Tests:**
- [x] `RecurrenceTest` (Parse/Render/Roundtrip, Ablehnung komplexer Regeln)
- [x] `EventFormTest`: Prefill (timed/all-day), `populatedFields`, UNTIL-Validierung
- [x] `EventWriteMapperTest`: Duration-Format, Dirty-Check-Pfade (Text-only,
Zeit einmalig/wiederkehrend, Recurrence an/aus, Reminder-only)
- [x] `CalendarRepositoryImplTest` + `FakeCalendarDataSource`: update-Pfade
- [x] `EventDetailMapperTest`: roher Titel
Bewusst nicht in v1.3 (→ v2.0): Konflikt-Dialog, Kalender-Wechsel beim
Bearbeiten (Sync-Adapter-Minenfeld, sperren auch alle Stock-Apps).
## v2.0 — Abschluss (Skizze)
- Quick-Add-Sheet (Titel + Zeit, Rest Defaults)
- Occurrence-Edit (Exception mit geänderten Werten)
- Konflikt-Dialog beim Speichern
- Changelog, F-Droid-Metadaten, Release-Tag

View File

@@ -0,0 +1,119 @@
# Calendula - Plan 04: Reminder Notifications (v1.4)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Calendula stellt Erinnerungen selbst als Notification zu (Etar-Modell).
Der Provider plant die Alarme und broadcastet
`android.intent.action.EVENT_REMINDER` — die sichtbare Notification postet er
**nicht**, das muss eine Kalender-App tun. Für Nutzer, deren einzige
Kalender-App Calendula ist, ist das essenziell, kein Nice-to-have.
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
On-Device-Review.
**Architecture:** Eigenes kleines Datenmodul `data/reminders/` neben
`data/calendar/` — der Receiver braucht weder Repository noch Flows. Schichtung
wie gehabt: `EventReminderReceiver` (Hilt-EntryPoint) →
`ReminderAlertStore` (Interface, Android-Impl auf `CalendarAlerts`) →
`ReminderNotifier` (NotificationManager). Domain bleibt pure Kotlin
(`ReminderAlert`-Modell, JVM-testbare Textformatierung).
**Recherche-Befunde (AOSP `CalendarAlarmManager` + Etar, 2026-06-11):**
1. Der Provider legt `CalendarAlerts`-Rows **nur für `METHOD_ALERT`-Reminder**
an (AOSP-Query: `AND method=1`). Der im Roadmap-Eintrag geforderte
METHOD-Filter (E-Mail überspringen) passiert also schon upstream — wir
filtern nicht doppelt. Calendula schreibt eigene Reminder ohnehin als
`METHOD_ALERT`.
2. Der Broadcast ist implizit (Action + `content://com.android.calendar/…`-URI,
Extra `alarmTime`). Etars Manifest-Receiver ist `exported="true"` mit
`<data android:scheme="content"/>` — das übernehmen wir (plus Host,
enger gefasst). Den URI-Inhalt werten wir nicht aus; wir queryen selbst
"fällig & noch SCHEDULED".
3. Etar postet aus dem Zustand `SCHEDULED FIRED` und verwaltet Dismiss über
eigene Services. Wir vereinfachen: nur `STATE_SCHEDULED AND alarmTime <= now`
posten, danach best-effort auf `FIRED` setzen (braucht `WRITE_CALENDAR`;
`SecurityException` wird geschluckt). Weggewischte Notifications kommen so
nie wieder, ohne deleteIntent-Maschinerie: FIRED-Rows fassen wir nicht an.
Re-Broadcasts ohne Write-Recht ersetzen still (Tag pro Alert +
`setOnlyAlertOnce`).
**Leitentscheidungen:**
1. **Kein eigenes Alarm-Scheduling** (kein `SCHEDULE_EXACT_ALARM`, kein
`BOOT_COMPLETED`, kein WorkManager): Zustellung hängt am Provider-Broadcast.
Etars Zusatz-Maschinerie (eigener AlarmScheduler) kommt erst, wenn sich
Zuverlässigkeit auf echten Geräten als Problem zeigt (Roadmap: bewusst
verschoben, ebenso Snooze-/Dismiss-Actions und Battery-Exemption).
2. **Toggle default ON, Onboarding-Schritt danach:** Nach dem Kalender-Grant
folgt ein zweiter Onboarding-Screen (gleiche Shell wie der Permission-
Screen): erklärt Reminder, warnt vor Duplikaten (zweite Kalender-App mit
aktiven Notifications), fragt `POST_NOTIFICATIONS` an (nur API 33+ zeigt
einen Dialog; minSdk 29). "Später" schaltet den Toggle aus. Der Schritt
erscheint genau einmal (`reminder_onboarding_done`-Pref) — auch für
v1.0v1.3-Upgrader, die das Feature so entdecken.
3. **Settings-Spiegel:** Abschnitt "Erinnerungen" mit demselben Toggle +
Duplikat-Hinweis. Einschalten fordert `POST_NOTIFICATIONS` kontextuell an,
wenn sie fehlt.
4. **Tap öffnet das Event-Detail:** Notification-Intent trägt
eventId/begin/end; `MainActivity` wird `singleTop`, reicht den Key als
Compose-State an `CalendarHost` durch (gleiches LongArray-Key-Muster wie
der Detail-Overlay selbst).
5. **Ein Kanal, einfache Inhalte:** Kanal "Erinnerungen"
(`IMPORTANCE_HIGH`), pro Alert eine Notification (Tag = Alert-Id):
Titel = Eventtitel (Fallback "(Ohne Titel)"), Text = Zeitspanne
(ganztägig: Datum, UTC gelesen) + Ort. Kein Grouping/Summary, kein
Vollbild-Alarm.
---
## Tasks
**Manifest / Resourcen:**
- [x] `POST_NOTIFICATIONS` ins Manifest; Receiver `.reminders.EventReminderReceiver`
`exported="true"`, Intent-Filter `EVENT_REMINDER` + `data scheme=content
host=com.android.calendar`; `MainActivity``launchMode="singleTop"`
- [x] Monochromes Notification-Icon `drawable/ic_notification.xml`
- [x] Strings DE+EN: Kanal, Onboarding-Copy, Settings-Abschnitt + Hinweis
**Prefs:**
- [x] `SettingsPrefs.remindersEnabled` (default **true**) + Setter;
`reminderOnboardingDone` (default false) + Setter; `SettingsPrefsTest`
**Data layer (`data/reminders/`):**
- [x] `ReminderAlert`-Modell (in `data/reminders/`, nicht domain — Alerts
erreichen nie einen Screen): alertId, eventId, begin/end als Millis,
title, location, isAllDay
- [x] `ReminderAlertStore` (Interface) + `AndroidReminderAlertStore`:
`dueAlerts(nowMillis)` = `CalendarAlerts` mit
`STATE_SCHEDULED AND ALARM_TIME <= now`;
`markFired(ids, nowMillis)` setzt STATE/RECEIVED_TIME/NOTIFY_TIME,
`SecurityException` → Log (Write-Recht optional)
- [x] `ReminderNotifier`: Kanal lazy anlegen, eine Notification pro Alert
(Tag = alertId, `setOnlyAlertOnce`, autoCancel, `when` = begin,
Category EVENT), Content-PendingIntent auf `MainActivity` mit
eventId/begin/end
- [x] Zeitspannen-Text als pure Funktion (JVM-testbar) + Test
**Receiver:**
- [x] `EventReminderReceiver` (`@AndroidEntryPoint`): Action prüfen,
`goAsync()`; raus, wenn Pref aus, READ_CALENDAR fehlt oder
Notifications systemseitig geblockt; sonst posten → `markFired`
**UI:**
- [x] Onboarding-Shell aus `PermissionScreen` extrahieren
(`OnboardingScaffold` + BenefitRow, intern wiederverwendet)
- [x] `NotificationOnboardingScreen` + ViewModel: Benefit-Rows (verpasst
nichts / Duplikat-Warnung), Primär-Button fordert `POST_NOTIFICATIONS`
(API 33+) und lässt den Toggle an, "Später" schaltet ihn aus; beide
setzen `reminder_onboarding_done`
- [x] `RootScreen`: Kalender-Gate → Reminder-Schritt (einmalig) → `CalendarHost`
- [x] `CalendarHost`: externer Detail-Key (Notification-Tap) wird wie ein
Event-Tap konsumiert; `MainActivity` parst Intent (onCreate +
onNewIntent) in Compose-State
- [x] Settings: Abschnitt "Benachrichtigungen" — Toggle (mit kontextuellem
Permission-Request beim Einschalten) + Duplikat-Hinweistext
**Abschluss:**
- [x] `./gradlew lint test assembleDebug` grün
- [x] CHANGELOG (`[Unreleased]`), ROADMAP-Status; **kein** Tag/Release vor
On-Device-Review