16 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:57:32 +02:00
78 changed files with 4852 additions and 653 deletions

View File

@@ -1,4 +1,4 @@
name: Build and Release to F-Droid
name: Release F-Droid repo + Gitea release
on:
push:
@@ -121,7 +121,12 @@ jobs:
$SUDO apk add --no-cache jq
fi
# Tag-only build steps. On a manual workflow_dispatch (ref = a branch,
# not a tag) these are skipped: the job then just re-signs the existing
# index with the configured repo key and re-uploads — used for key
# rotation / repo recovery without publishing a new APK.
- name: Set version from git tag
if: startsWith(github.ref, 'refs/tags/')
run: |
set -e
RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
@@ -135,8 +140,12 @@ jobs:
sed -i "s/versionName = \".*\"/versionName = \"$VERSION\"/" app/build.gradle.kts
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
grep -E 'versionName|versionCode' app/build.gradle.kts
# Export for later steps (F-Droid changelog, mapping asset name).
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "VERSION_CODE=$VERSION_CODE" >> "$GITHUB_ENV"
- name: Setup Android Keystore
if: startsWith(github.ref, 'refs/tags/')
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
@@ -155,6 +164,7 @@ jobs:
run: chmod +x ./gradlew
- name: Build release APK
if: startsWith(github.ref, 'refs/tags/')
run: ./gradlew assembleRelease
- name: Setup F-Droid Server Tools
@@ -165,29 +175,48 @@ jobs:
$SUDO apt-get install -y sshpass python3-pip
pip3 install --break-system-packages --upgrade fdroidserver
- name: Initialize or fetch F-Droid Repository
- name: Fetch existing F-Droid repo from Hetzner
env:
HOST: ${{ secrets.HETZNER_HOST }}
USER: ${{ secrets.HETZNER_USER }}
PASS: ${{ secrets.HETZNER_PASS }}
run: |
set -euo pipefail
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=20"
mkdir -p fdroid
sshpass -p "$PASS" sftp -o StrictHostKeyChecking=no "$USER@$HOST" <<'SFTP'
-mkdir dev
-mkdir dev/fdroid
-mkdir dev/fdroid/repo
SFTP
sshpass -p "$PASS" scp -o StrictHostKeyChecking=no -r "$USER@$HOST:dev/fdroid/." fdroid/ || (cd fdroid && fdroid init)
# Pull only the published repo/ (all apps' APKs), any per-app
# metadata, and the repo icon — enough to rebuild the index without
# dropping the other apps. The signing key is deliberately NOT pulled
# from the box; it comes from CI secrets in the next step so it never
# has to live in the web-served tree.
sshpass -p "$PASS" scp $SSH_OPTS -r "$USER@$HOST:dev/fdroid/repo" fdroid/ 2>/dev/null || true
sshpass -p "$PASS" scp $SSH_OPTS -r "$USER@$HOST:dev/fdroid/metadata" fdroid/ 2>/dev/null || true
sshpass -p "$PASS" scp $SSH_OPTS "$USER@$HOST:dev/fdroid/icon.png" fdroid/ 2>/dev/null || true
mkdir -p fdroid/repo fdroid/metadata
- name: Ensure F-Droid repo signing key and icon
- name: Restore F-Droid signing key and config from secrets
env:
FDROID_KEYSTORE_BASE64: ${{ secrets.FDROID_KEYSTORE_BASE64 }}
FDROID_CONFIG_BASE64: ${{ secrets.FDROID_CONFIG_BASE64 }}
run: |
cd fdroid
mkdir -p repo/icons
if [ ! -f keystore.p12 ]; then
fdroid update --create-key
set -euo pipefail
# Fail loudly if the repo key is not configured. NEVER auto-generate
# one: a fresh key changes the repo fingerprint and breaks every
# user's pinned repo. (Replaces the old `fdroid update --create-key`
# path, which silently rotated the key on a wiped server.)
if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then
echo "ERROR: FDROID_KEYSTORE_BASE64 / FDROID_CONFIG_BASE64 secrets are not set." >&2
echo "Refusing to continue — will not auto-generate a new repo key." >&2
exit 1
fi
echo "$FDROID_KEYSTORE_BASE64" | base64 --decode > fdroid/keystore.p12
echo "$FDROID_CONFIG_BASE64" | base64 --decode > fdroid/config.yml
test -s fdroid/keystore.p12
test -s fdroid/config.yml
mkdir -p fdroid/repo/icons
- name: Copy new APK to repo
if: startsWith(github.ref, 'refs/tags/')
run: |
set -e
mkdir -p fdroid/repo
@@ -203,12 +232,33 @@ jobs:
mkdir -p fdroid/metadata
cp -r fdroid-metadata/* fdroid/metadata/
# Per-version "What's New" for F-Droid clients: the tag's CHANGELOG
# section written to changelogs/<versionCode>.txt (same extraction as the
# Gitea release notes). en-US only — F-Droid falls back to it for locales
# without their own changelog. fdroid update bakes this into the index.
- name: Generate F-Droid changelog for this version
if: startsWith(github.ref, 'refs/tags/')
run: |
set -e
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > /tmp/changelog.txt
sed -i -e '/./,$!d' /tmp/changelog.txt
if [ ! -s /tmp/changelog.txt ]; then
echo "See CHANGELOG.md for $VERSION." > /tmp/changelog.txt
fi
CL_DIR="fdroid/metadata/de.jeanlucmakiola.calendula/en-US/changelogs"
mkdir -p "$CL_DIR"
cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt"
echo "Wrote $CL_DIR/${VERSION_CODE}.txt"
- name: Generate F-Droid Index
run: |
cd fdroid
fdroid update -c
- name: Upload Repo to Hetzner
- name: Upload repo/ to Hetzner
env:
HOST: ${{ secrets.HETZNER_HOST }}
USER: ${{ secrets.HETZNER_USER }}
@@ -219,6 +269,113 @@ jobs:
sshpass -p "$PASS" sftp $SSH_OPTS "$USER@$HOST" <<'SFTP'
-mkdir dev
-mkdir dev/fdroid
-mkdir dev/fdroid/repo
SFTP
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/. "$USER@$HOST:dev/fdroid/"
# Publish the signed repo/ plus metadata/ (descriptions, screenshots,
# per-version changelogs) so changelog history survives across
# releases. keystore.p12 and config.yml are NEVER uploaded, so they
# can't re-enter the web-served tree; nginx serves only repo/ anyway.
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo fdroid/metadata "$USER@$HOST:dev/fdroid/"
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
# Attached to the Gitea release (it's not an APK, so it fits the
# no-binaries rule). Best-effort: never fail a release over it.
- name: Attach R8 mapping to Gitea release
if: startsWith(github.ref, 'refs/tags/')
continue-on-error: true
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
MAP="app/build/outputs/mapping/release/mapping.txt"
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
ASSET="mapping-${VERSION:-$TAG}.txt.gz"
gzip -c "$MAP" > "/tmp/$ASSET"
# The release is created by the gitea-release job; ensure it exists
# (idempotent) so this job doesn't race it to a 404.
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$ID" ]; then
ID=$(curl -s -X POST -H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\"}" \
"$API/releases" | jq -r '.id // empty')
fi
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
# Replace any prior asset of the same name (re-run safe).
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$ASSET" \
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
# A Gitea release per tag, carrying the tag's CHANGELOG section as its
# notes. Deliberately no APK assets — distribution stays with the F-Droid
# repo; the release is the human-readable record. Gated on the tests-only
# ci job (not the deploy) so notes appear even if the F-Droid upload has
# an infrastructure hiccup.
gitea-release:
needs: ci
if: startsWith(github.ref, 'refs/tags/')
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract changelog section for this tag
run: |
set -e
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
VERSION="${TAG#v}"
# Everything between "## [<version>]" and the next "## [" heading.
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
# Trim leading blank lines.
sed -i -e '/./,$!d' release-notes.md
if [ ! -s release-notes.md ]; then
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
fi
echo "--- release notes ---"
cat release-notes.md
- name: Create Gitea release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
python3 - "$TAG" <<'PY' > payload.json
import json, sys
print(json.dumps({
"tag_name": sys.argv[1],
"name": sys.argv[1],
"body": open("release-notes.md").read(),
"draft": False,
"prerelease": False,
}))
PY
# Upsert: the build-and-deploy job may have created a bare release
# first (to attach the mapping asset), so PATCH the notes if it
# exists, otherwise POST a new one. Both paths are re-run safe.
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
ID=$(python3 -c "import json,sys; d=json.load(open('existing.json')); print(d.get('id',''))" 2>/dev/null || true)
if [ -n "$ID" ]; then
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @payload.json "$API/releases/$ID")
OK=200
else
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @payload.json "$API/releases")
OK=201
fi
cat response.json
if [ "$CODE" != "$OK" ]; then
echo "Release upsert failed with HTTP $CODE (expected $OK)"
exit 1
fi

4
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -53,7 +53,7 @@ after v0.6 (full event read) plus the onboarding-screen polish pass.
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
## v2.0 — Write Support (in progress)
## v2.0 — Write Support (complete, shipped 2026-06-11)
Delivered in four releasable slices (plan:
`docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a
@@ -64,8 +64,48 @@ guide here, not a contract — scope per slice is decided as we go.
| 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, series edit, reminders, simple recurrence picker | planned |
| v2.0 | Quick-add, occurrence edit, conflict dialog, polish, release | planned |
| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) |
| v1.4 | Reminder notifications — see below | complete (shipped 2026-06-11) |
| v2.0 | Conflict dialog, polish pass (store copy refresh, F-Droid screenshots), release | complete (shipped 2026-06-11) |
v2.0 scope was re-cut on 2026-06-11, after v1.4:
- **Occurrence edit** already shipped early, in v1.3.
- **Quick-add** is **cut from scope**: the full form already opens prefilled
(visible day, last-used calendar, optional fields hidden), so the sheet
would only save one screen transition while adding a second create-surface
to maintain. Revisit only if real-world feedback says creation feels heavy.
- **Calendar switching while editing** moves to the v3 backlog (sync-adapter
minefield: `CALENDAR_ID` is sync-adapter-owned, AOSP locks the field; an
honest implementation is copy+delete like Google Calendar, with sync-identity
and attendee side effects).
- **Conflict dialog** stays (plan 03, decision 5): on save, compare against
the row as it was when the form loaded; on external change, ask
overwrite / discard. Closes the silent-clobber gap on synced calendars.
## 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
@@ -73,5 +113,53 @@ guide here, not a contract — scope per slice is decided as we go.
- Full-text search
- Tablet / foldable layouts
- Optional: ICS file import (drag-and-drop)
- Optional: move event to another calendar (copy+delete model with a
consequences warning — deferred from v2.0, see above)
Order is indicative — community feedback after V1 may re-prioritize.
## Idea backlog — Daily-driver polish (captured 2026-06-11, all approved as ideas, unscheduled)
Interaction:
- Tap/long-press an empty slot in day/week → create form prefilled with that time
- Drag & drop rescheduling in day/week (recurring drops reuse the scope dialog) — big-ticket, own slice
- Agenda view (fourth view: upcoming events grouped by day; natural widget data source)
- Pinch-to-zoom time scale in day/week
Reminders, round two:
- Snooze + dismiss actions on the notification (snooze needs an exact-alarm/WorkManager decision)
- Settings default reminder applied to new events
Event niceties:
- Duplicate event (detail action → prefilled create form)
- Per-event color (`Events.EVENT_COLOR`, OptionCard picker in the form)
- Share event as .ics + open/receive .ics into a prefilled create form (front-runs v3 ICS import)
Small delights:
- App shortcuts (launcher long-press → New event), maybe a quick-settings tile
- Jump to date (un-cut from V1 — drawer date picker)
Consciously rejected: travel time / weather / smart suggestions (network,
core-promise conflict), natural-language quick entry (high effort,
locale-fragile, prefilled form already covers fast entry).
## Idea backlog — Locations & People (captured 2026-06-11, undecided)
Beyond classic calendar-client scope; discussed, deliberately not planned
in detail yet:
- **Contact address picker** for the location field via the system picker
(`ACTION_PICK` on postal addresses) — one-shot, needs no READ_CONTACTS,
fits the privacy story. Same mechanism later for picking emails.
- **OSM address autocomplete** in the location field (type "Brandenburger
Tor" → tap suggestion → resolved address inserted). Backend would be
Photon (Nominatim's public policy forbids autocomplete). **Requires the
INTERNET permission** — first dent in the "no network access" promise;
if built: opt-in (off by default), honest copy, configurable endpoint
for self-hosters, onboarding footnote + F-Droid copy reworded. This
trade-off is an explicit go/no-go decision before any work starts.
- **Inline contact suggestions** while typing (needs READ_CONTACTS) — only
if the picker proves clunky.
- **Attendee editing / invites from contacts** — own milestone; writing
`Attendees` rows touches sync-adapter invitation behavior (Google vs
DAVx5 differ).

View File

@@ -4,12 +4,10 @@
## Status
**Milestone:** v2.0 — Write support (milestone 2, in progress)
**Phase:** v1.2.1 shipped 2026-06-11 — the create-form polish pass after
Jean-Luc's on-device review (v1.2.0 and v1.1.0 shipped the same day).
Milestone 2 runs in four slices
(`docs/superpowers/plans/2026-06-11-03-write-support.md`); next up is v1.3
(edit event). Note: UI slices now hold release until his explicit approval.
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11.
**Phase:** between milestones. Next: v3.0 (power-user features) and the
go/no-go on the "Locations & People" idea backlog (`ROADMAP.md`). Docs
pass done (ARCHITECTURE.md, README overhaul, planning docs refreshed).
## Progress
@@ -44,8 +42,39 @@ Milestone 2 runs in four slices
with provider-correct all-day normalisation (UTC midnights, exclusive end),
domain/mapper/repository tests
- [x] v1.3 edit event (shipped 2026-06-11) — `EventEditScreen` reused for
edit (detail-screen Edit action, `canModify`-gated, contextual WRITE
upgrade), dirty-checked partial `update` on the Events row (recurring:
series DTSTART moves by the user's delta, DURATION instead of DTEND),
reminder diff by minutes (kept rows keep their method), simple recurrence
picker (FREQ/INTERVAL/UNTIL/COUNT; complex RRULEs preserved verbatim and
shown humanized), `EventFormField.Recurrence` incl. settings default,
recurrence also available on create; domain/mapper/repository tests.
Review round 1: weekly BYDAY day-toggles in the custom picker ("every week
on Mon+Fri"). Review rounds 24: occurrence edit pulled forward from v2.0
and made three-way like delete ("this" = exception row via
`CONTENT_EXCEPTION_URI`, "this and following" = series split, "all" =
series update); delete equally three-way (truncation via RRULE UNTIL);
the edit-scope question moved to save time (Google model) — dirty
recurring saves park in `SaveUiState.AwaitingScope`, a changed rule drops
the "only this event" option
- [x] v1.4 reminder notifications (shipped 2026-06-11) — exported
`EVENT_REMINDER` receiver → `CalendarAlerts` (SCHEDULED & due) →
dedicated channel, tap opens detail (singleTop deep link); best-effort
FIRED marking; one-time onboarding step requesting `POST_NOTIFICATIONS`
with duplicate-reminders warning; Settings mirror. Provider only fires
`METHOD_ALERT` rows (AOSP-verified), so email reminders never reach us
- [x] v2.0 conflict dialog + store polish (shipped 2026-06-11 as v2.0.0) —
`EditSnapshot` compare on save (overwrite/discard; deleted → close),
quick-add cut, calendar-switch → v3 backlog; F-Droid/README copy
refreshed, fastlane screenshots DE+EN captured on-device
## Next
1. v1.3 — edit event: reuse the form, series edit, reminder edit, simple
recurrence picker
2. Monitor the F-Droid build/publish for v1.1.0 / v1.2.0
1. Decide the "Locations & People" go/no-go (INTERNET permission question)
— see `ROADMAP.md` idea backlog
2. v3.0 scoping: widget, full-text search, tablet layouts, ICS import,
calendar-move
3. Monitor the F-Droid build/publish for the v1.4.0 / v2.0.0 tags

View File

@@ -7,6 +7,107 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.1.0] — 2026-06-15
### Added
- The month view now shows real events in each day instead of coloured
dots: all-day and multi-day events render as continuous bars at the top
(a multi-day event is one connected bar across the days it spans, not a
chip per day), with single-day timed events as filled pills beneath.
Up to three rows show per day, then a "+N" dot indicator for the rest.
Each day keeps a rounded surface background, matching the week and day
views; today is marked with a filled circle on its number
- The slide-out panel now has a "View" section to switch between Month,
Week, and Day, mirroring the top-bar switcher pill — tapping a view
selects it and closes the drawer. The current view is highlighted
### Fixed
- Typing in the event title, location, and description fields no longer
makes the cursor jump around: the form state's round-trip to the UI was
hopping to a background dispatcher, so the text field saw a lagging value
while typing. Only the calendar/preferences reads stay off the main
thread now; the keystroke path is synchronous again
## [2.0.0] — 2026-06-11
### Added
- Conflict handling when saving an edit: if the event changed elsewhere
(sync, another device) while the form was open, saving now asks whether
to keep or discard your changes instead of silently overwriting the
edited fields — and tells you when the event was deleted in the meantime.
"Keep" still writes only the fields you touched; external changes to
untouched fields survive either way
- F-Droid store screenshots (German + English), captured with demo data
### Changed
- F-Droid description and README no longer claim the app is read-only —
they now describe write support and reminder delivery
### Fixed
- `versionName`/`versionCode` bumped to 2.0.0 / 13 — closing out the
write-support milestone (v1.1 through v2.0)
## [1.4.0] — 2026-06-11
### Added
- Reminder notifications (v1.4): Calendula now delivers event reminders as
notifications itself — the system schedules them but posts nothing, so a
calendar app must (essential when Calendula is the only one installed).
Due reminders appear on a dedicated "Event reminders" channel; tapping one
opens the event's detail screen. Email reminders are never posted (the
provider only schedules alert-type reminders)
- A one-time onboarding step after the calendar grant introduces reminders,
requests the notification permission (Android 13+), and warns that a second
calendar app with notifications on will duplicate them. "Not now" leaves
the feature off
- Settings gained a "Notifications" section mirroring the choice: an event-
reminders toggle (default on) with the duplicate-reminders hint; turning it
on re-requests the notification permission when missing
### Fixed
- `versionName`/`versionCode` bumped to 1.4.0 / 12
## [1.3.0] — 2026-06-11
### Added
- Event editing: a pencil action on the detail screen (writable calendars
only) opens the event form prefilled with the event. Only fields you
actually changed are written back; saving an untouched form is a no-op.
Sections holding data are always shown, regardless of the form-field
defaults; the calendar itself can't be changed while editing
- Recurring events — scoped writes, chosen when saving (Google model):
"only this event" (a modified-occurrence exception), "this and all
following" (the series is split at the occurrence), or "all events in
the series". Changing the recurrence rule rules out "only this event"
- Deleting a recurring event gained the middle option too: "this and all
following events" ends the series just before the chosen occurrence
- Recurrence picker (create and edit): one-tap daily/weekly/monthly/yearly
presets plus a custom step with interval + unit, weekday toggles for
weekly rules ("every week on Mon and Fri"), and an end condition (never /
on a date / after a number of times). Rules the picker can't express
(e.g. "second Thursday monthly") are shown humanized and preserved
verbatim unless replaced. Recurrence also joined the optional form
fields and their settings defaults
- Validation: a repeat that would end before the event starts is flagged
(it would otherwise vanish from every view)
### Changed
- Editing reminders reconciles against the provider's actual rows:
reminders you didn't touch keep their method (e.g. email reminders on
synced events survive unrelated edits)
- The contextual WRITE_CALENDAR upgrade for v1.0 installs covers the edit
action like delete
### Fixed
- Splitting a series ("this and following") sends the complete time-column
set in one update, so the provider regenerates its cached instances — an
RRULE-only update left a stale duplicate of the tapped occurrence on the
split day
- RRULE UNTIL values are written as the local end of day expressed in UTC
(instead of a flat `T235959Z`), so recurrences can't leak an extra day in
timezones ahead of UTC
- `versionName`/`versionCode` bumped to 1.3.0 / 11
## [1.2.1] — 2026-06-11
### Added

135
README.md
View File

@@ -1,43 +1,120 @@
# Calendula
<div align="center">
A modern Material 3 Expressive calendar app for Android.
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/icon.png" width="112" alt="Calendula icon">
Calendula is named after the flower of the same name, whose name comes from
the Latin *kalendae* — the first day of the month — the same root as the
word "calendar". Calendula reads from Android's built-in `CalendarContract`,
so any calendar source synced to your device (CalDAV via DAVx5, Google,
local, WebCal subscriptions, ...) is shown.
<h1>Calendula</h1>
## Features (V1)
<p><strong>A modern Material 3 Expressive calendar for Android.</strong><br>
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
- Month, Week, and Day views
- Read-only event details (write support comes in V2)
- Multi-calendar visibility toggle
- Material You Dynamic Color (Android 12+)
- Light/Dark theme follows system
- German + English UI
<p>
<a href="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions"><img src="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a>
</p>
## Building
<p>
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/01-week.png" width="19%" alt="Week view">&nbsp;
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/02-month.png" width="19%" alt="Month view">&nbsp;
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">&nbsp;
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/05-edit.png" width="19%" alt="Event form">&nbsp;
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding">
</p>
Requires Android SDK 36 and JDK 17. The Gradle wrapper is checked in, so no host Gradle install is needed:
</div>
Calendula is named after the flower whose name — like the word *calendar*
comes from the Latin *kalendae*, the first day of the month. It lives
entirely on top of Android's `CalendarContract`: any calendar synced to your
device (CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply
appears, and everything you create or edit syncs back the same way. No own
database, no sync stack reinvented.
## ✨ Features
**Calendar**
- Month, week, and day views with a one-tap view switcher
- Full event details — attendees and their responses, reminders, recurrence
(humanized), availability, visibility, foreign time zones
- Per-calendar visibility toggle, grouped by account
**Editing**
- Create, edit, and delete events — including recurring events with scoped
writes: *only this event*, *this and all following*, or *the whole series*
- Recurrence picker with one-tap presets and custom rules (interval, weekday
toggles, end conditions); rules it can't express are preserved verbatim
- Conflict-safe saves: if an event changed elsewhere while you were editing,
Calendula asks instead of silently overwriting
- Read-only calendars (WebCal, birthdays) are detected and respected
**Reminders**
- Event reminders delivered by Calendula itself as notifications —
essential when it's your only calendar app, since Android delegates
reminder delivery to calendar apps
- Tap a reminder to land on the event
**Design & privacy**
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
expressive motion and shapes, light/dark theme
- German and English UI, per-app language setting
- **Zero telemetry, zero analytics, no internet permission** — your data
never leaves the device
## 📦 Install
Calendula ships through a self-hosted F-Droid repository; every version tag
is built, signed, and published there automatically.
1. Install an F-Droid client ([F-Droid](https://f-droid.org), Droid-ify, Neo
Store, …).
2. Add the repository — open this link on your phone, or paste it under
*Settings → Repositories → Add*:
```
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425
```
<sub>Repo: `https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo` ·
fingerprint (SHA-256):
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
3. Refresh, search for **Calendula**, install. Updates arrive like any
other F-Droid app.
Alternatively, build from source — see below.
## 🛠 Building
Requires Android SDK 36+ and JDK 17. The Gradle wrapper is checked in:
```bash
# Build debug APK
./gradlew assembleDebug
# Run unit tests
./gradlew test
# Run lint
./gradlew lint
./gradlew assembleDebug # debug APK
./gradlew test # JVM unit tests
./gradlew lint # Android lint
```
If your default JDK is something other than 17, set `JAVA_HOME` explicitly:
If your default JDK is not 17, set `JAVA_HOME` explicitly.
```bash
JAVA_HOME=/path/to/jdk-17 ./gradlew assembleDebug
```
## 🏗 Architecture
## License
Single-activity Compose app, layered `UI → Repository → DataSource →
CalendarContract`, observer-driven refresh, JVM-first tests. The full tour —
including the recurring-write and reminder pipelines — lives in
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## 🗺 Roadmap
Shipped: read (v1.0), write (v1.1v2.0), reminder delivery (v1.4).
Next up: power-user features — widget, search, tablet layouts. The living
roadmap is in [.planning/ROADMAP.md](.planning/ROADMAP.md), the release
history in [CHANGELOG.md](CHANGELOG.md).
## 📜 License
[MIT](LICENSE) — Jean-Luc Makiola, 2026

View File

@@ -23,8 +23,13 @@ android {
applicationId = "de.jeanlucmakiola.calendula"
minSdk = 29
targetSdk = 36
versionCode = 10
versionName = "1.2.1"
// The git tag is the single source of truth for released builds: at
// release time .gitea/workflows/release.yaml derives both fields from
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
// default; keep them matching the latest released tag. See docs/RELEASING.md.
versionCode = 20100
versionName = "2.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -4,6 +4,7 @@
<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"
@@ -19,6 +20,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -26,6 +28,20 @@
</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

@@ -17,7 +17,9 @@ 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
@@ -37,6 +39,44 @@ interface CalendarDataSource {
/** 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)
@@ -101,7 +141,14 @@ class AndroidCalendarDataSource @Inject constructor(
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())
@@ -128,6 +175,186 @@ class AndroidCalendarDataSource @Inject constructor(
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),

View File

@@ -15,11 +15,37 @@ interface CalendarRepository {
/** 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) :

View File

@@ -74,13 +74,45 @@ class CalendarRepositoryImpl @Inject constructor(
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

@@ -6,6 +6,7 @@ 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
@@ -37,6 +38,118 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
)
}
/**
* 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

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

@@ -86,6 +86,31 @@ class SettingsPrefs @Inject constructor(
}
}
/**
* 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(',')
@@ -98,6 +123,8 @@ class SettingsPrefs @Inject constructor(
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

@@ -1,6 +1,11 @@
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
@@ -19,6 +24,12 @@ data class EventForm(
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,
)
/**
@@ -29,6 +40,7 @@ enum class EventFormField {
Location,
Description,
Reminders,
Recurrence,
Availability,
Visibility,
}
@@ -37,6 +49,8 @@ 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,
}
/**
@@ -44,8 +58,88 @@ enum class EventFormProblem {
* 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() },
)
}
/**
* What the edit form saw when it loaded — compared against a fresh read at
* save time to detect external changes (sync, another device) that landed
* while the form was open. The raw row times ride along because
* [toEditForm] derives the form's times from the *tapped occurrence*, so
* re-deriving with the same occurrence would mask an externally moved
* event. Not covered (the form can't write them, and the dirty-checked
* write can't clobber them): attendees, status, the user's own response,
* reminder methods, and a recurring event's duration.
*/
data class EditSnapshot(
val form: EventForm,
/** The raw Events-row times (for recurring events: the series anchor). */
val rowStart: Instant,
val rowEnd: Instant,
)
fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZone): EditSnapshot =
EditSnapshot(
form = toEditForm(beginMillis, endMillis, zone),
rowStart = instance.start,
rowEnd = instance.end,
)
/**
* The optional sections that hold a value in [form] — when editing, these
* must be visible regardless of the user's default-fields setting, or the
* data they carry would be invisible (though still preserved).
*/
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (location.isNotBlank()) add(EventFormField.Location)
if (description.isNotBlank()) add(EventFormField.Description)
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
if (rrule != null) add(EventFormField.Recurrence)
if (availability != Availability.Busy) add(EventFormField.Availability)
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
}
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

@@ -115,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
@@ -29,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 }
@@ -61,6 +71,15 @@ 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.)
@@ -76,6 +95,15 @@ fun CalendarHost(modifier: Modifier = Modifier) {
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()) {
@@ -117,6 +145,10 @@ fun CalendarHost(modifier: Modifier = Modifier) {
beginMillis = key[1],
endMillis = key[2],
onBack = { detailKey = null },
onEdit = {
heldEditKey = key
editKey = key
},
)
}
}
@@ -131,6 +163,26 @@ fun CalendarHost(modifier: Modifier = Modifier) {
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

@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -27,16 +26,19 @@ import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
* Visual language (kept deliberately small so sizes don't drift):
* - Drawer title — `titleLarge`
* - Section headers (e.g. "Calendars") — `titleSmall`, primary, text only
* - Nav items (Today / Settings) — Material `NavigationDrawerItem`
* - Nav items (the views, Today / Settings) — Material `NavigationDrawerItem`
* (`labelLarge` label + a single 24dp leading icon)
*
* Hosts the per-calendar visibility filter (M3) inline — the calendar list with
* its checkboxes lives here rather than in a separate sheet — plus the "today"
* jump and a Settings entry (M4). The host screen owns the drawer state.
* The "View" section mirrors the top-bar switcher pill: tapping a view here
* selects it (and closes the drawer) rather than cycling. Also hosts the
* per-calendar visibility filter (M3) inline — the calendar list with its
* checkboxes lives here rather than in a separate sheet — plus a Settings
* entry (M4). The host screen owns the drawer state.
*/
@Composable
fun CalendarDrawer(
onToday: () -> Unit,
currentView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onSettings: () -> Unit,
) {
ModalDrawerSheet {
@@ -47,14 +49,17 @@ fun CalendarDrawer(
modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp),
)
HorizontalDivider()
Spacer(Modifier.height(8.dp))
DrawerSectionHeader(stringResource(R.string.view_section))
IMPLEMENTED_VIEWS.forEach { view ->
NavigationDrawerItem(
icon = { Icon(Icons.Filled.Today, contentDescription = null) },
label = { Text(stringResource(R.string.month_today_action)) },
selected = false,
onClick = onToday,
icon = { Icon(view.icon, contentDescription = null) },
label = { Text(stringResource(view.labelRes)) },
selected = view == currentView,
onClick = { onSelectView(view) },
modifier = Modifier.padding(horizontal = 12.dp),
)
}
Spacer(Modifier.height(8.dp))
HorizontalDivider()

View File

@@ -1,5 +1,13 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarViewDay
import androidx.compose.material.icons.filled.CalendarViewMonth
import androidx.compose.material.icons.filled.CalendarViewWeek
import androidx.compose.ui.graphics.vector.ImageVector
import de.jeanlucmakiola.calendula.R
/**
* The top-level calendar views the user can switch between (spec M1).
* Day is declared but not yet implemented (v0.5) — see [IMPLEMENTED_VIEWS].
@@ -10,6 +18,23 @@ enum class CalendarView {
Day,
}
/** Switcher label, shared by the top-bar pill and the drawer's View section. */
@get:StringRes
val CalendarView.labelRes: Int
get() = when (this) {
CalendarView.Month -> R.string.view_month
CalendarView.Week -> R.string.view_week
CalendarView.Day -> R.string.view_day
}
/** Leading icon for the view in the drawer's View section. */
val CalendarView.icon: ImageVector
get() = when (this) {
CalendarView.Month -> Icons.Filled.CalendarViewMonth
CalendarView.Week -> Icons.Filled.CalendarViewWeek
CalendarView.Day -> Icons.Filled.CalendarViewDay
}
/**
* Views that actually have a screen today. The view-switcher pill cycles
* through these in order.

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

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

View File

@@ -157,7 +157,11 @@ fun DayScreen(
gesturesEnabled = drawerState.isOpen,
drawerContent = {
CalendarDrawer(
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
currentView = selectedView,
onSelectView = { view ->
onSelectView(view)
scope.launch { drawerState.close() }
},
onSettings = {
onOpenSettings()
scope.launch { drawerState.close() }

View File

@@ -5,7 +5,6 @@ import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.icu.text.ListFormatter
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -35,6 +34,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Place
@@ -74,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
@@ -90,13 +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
@@ -109,7 +109,9 @@ import kotlin.time.Instant
* 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 from here (v1.1); edit follows in v1.3.
* 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
@@ -118,6 +120,7 @@ fun EventDetailScreen(
beginMillis: Long,
endMillis: Long,
onBack: () -> Unit,
onEdit: () -> Unit,
viewModel: EventDetailViewModel = hiltViewModel(),
) {
LaunchedEffect(eventId, beginMillis, endMillis) {
@@ -133,20 +136,35 @@ fun EventDetailScreen(
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 confirm dialog.
// 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) showDeleteDialog = true
if (granted) {
if (pendingEdit) onEdit() else showDeleteDialog = true
}
val onDeleteClick = {
val granted = ContextCompat.checkSelfPermission(
pendingEdit = false
}
val hasWritePermission = {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
if (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)
}
}
@@ -189,6 +207,15 @@ fun EventDetailScreen(
// birthday calendars etc. are read-only at the provider level.
val s = state
if (s is EventDetailUiState.Success && s.canModify) {
IconButton(
onClick = onEditClick,
enabled = deleteState != DeleteUiState.Deleting,
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = stringResource(R.string.event_detail_edit),
)
}
IconButton(
onClick = onDeleteClick,
enabled = deleteState != DeleteUiState.Deleting,
@@ -223,9 +250,9 @@ fun EventDetailScreen(
if (showDeleteDialog && loaded is EventDetailUiState.Success) {
DeleteEventDialog(
isRecurring = !loaded.detail.rrule.isNullOrBlank(),
onConfirm = { wholeSeries ->
onConfirm = { scope ->
showDeleteDialog = false
viewModel.delete(wholeSeries)
viewModel.delete(scope)
},
onDismiss = { showDeleteDialog = false },
)
@@ -234,15 +261,16 @@ fun EventDetailScreen(
/**
* Delete confirmation. Recurring events choose between cancelling just the
* tapped occurrence (default) and removing the whole series.
* tapped occurrence (default), truncating the series from it onwards, and
* removing the whole series.
*/
@Composable
private fun DeleteEventDialog(
isRecurring: Boolean,
onConfirm: (wholeSeries: Boolean) -> Unit,
onConfirm: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
) {
var wholeSeries by rememberSaveable { mutableStateOf(false) }
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
@@ -258,13 +286,18 @@ private fun DeleteEventDialog(
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { wholeSeries = false },
selected = !wholeSeries,
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 = { wholeSeries = true },
selected = wholeSeries,
onClick = { scope = RecurringWriteScope.AllEvents },
selected = scope == RecurringWriteScope.AllEvents,
)
}
} else {
@@ -272,7 +305,9 @@ private fun DeleteEventDialog(
}
},
confirmButton = {
TextButton(onClick = { onConfirm(if (isRecurring) wholeSeries else true) }) {
TextButton(
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
) {
Text(
text = stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
@@ -707,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

@@ -7,6 +7,7 @@ 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
@@ -78,20 +79,23 @@ class EventDetailViewModel @Inject constructor(
}
/**
* Delete the open event. [wholeSeries] is meaningful only for recurring
* events: false cancels just the tapped occurrence. Result lands in
* [deleteState]; the screen consumes it via [consumeDeleteResult].
* 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(wholeSeries: Boolean) {
fun delete(scope: RecurringWriteScope) {
val target = _target.value ?: return
if (_deleteState.value == DeleteUiState.Deleting) return
viewModelScope.launch {
_deleteState.value = DeleteUiState.Deleting
_deleteState.value = try {
if (wholeSeries) {
when (scope) {
RecurringWriteScope.AllEvents ->
repository.deleteEvent(target.eventId)
} else {
RecurringWriteScope.ThisEvent ->
repository.deleteOccurrence(target.eventId, target.beginMillis)
RecurringWriteScope.ThisAndFollowing ->
repository.deleteEventFromOccurrence(target.eventId, target.beginMillis)
}
DeleteUiState.Deleted
} catch (e: CancellationException) {

View File

@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
@@ -42,6 +43,7 @@ import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.VisibilityOff
@@ -88,6 +90,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
@@ -101,49 +104,82 @@ import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.EventFormProblem
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
import de.jeanlucmakiola.calendula.domain.toRRule
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.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.isoDayNumber
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toKotlinDayOfWeek
import kotlinx.datetime.toJavaLocalTime
import kotlinx.datetime.toLocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle
import java.time.temporal.WeekFields
import java.util.Locale
import kotlin.time.Clock
/**
* Full-screen event form (v1.2: create only). Opens prefilled from the FAB's
* anchor date; Save validates, writes via the repository, and closes. The
* calendar picker offers only writable calendars.
* Full-screen event form: create (v1.2, from the FAB's anchor date) and edit
* (v1.3, [editKey] = eventId + the tapped occurrence's begin/end millis).
* Save validates and writes via the repository — a dirty recurring event
* first asks how far the change reaches (this occurrence / this and
* following / whole series) — then closes. The calendar picker offers only
* writable calendars; when editing, the calendar is fixed.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventEditScreen(
initialDateIso: String?,
onClose: () -> Unit,
onSaved: () -> Unit,
editKey: LongArray? = null,
viewModel: EventEditViewModel = hiltViewModel(),
) {
LaunchedEffect(initialDateIso) {
LaunchedEffect(initialDateIso, editKey) {
if (editKey != null) {
viewModel.openForEdit(
eventId = editKey[0],
beginMillis = editKey[1],
endMillis = editKey[2],
)
} else {
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
viewModel.openNew(date)
}
}
val state by viewModel.state.collectAsStateWithLifecycle()
val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle()
// The form is intentionally forgotten on every close (cancel or save) so
// the next FAB tap starts clean; it survives rotation because openNew
// no-ops while a form is set.
// the next open starts clean; it survives rotation because openNew /
// openForEdit no-op while a form is set.
val close = {
viewModel.reset()
onClose()
}
BackHandler(onBack = close)
// The event vanished between the detail screen and the edit tap — fall
// back to the detail screen, which shows its own failure state.
LaunchedEffect(loadFailed) {
if (loadFailed) close()
}
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
@@ -169,7 +205,10 @@ fun EventEditScreen(
val writeDeniedMessage = stringResource(R.string.event_edit_write_denied)
LaunchedEffect(state?.saveState) {
when (state?.saveState) {
SaveUiState.Saved -> close()
SaveUiState.Saved -> {
viewModel.reset()
onSaved()
}
SaveUiState.Failed -> {
viewModel.consumeSaveResult()
snackbarHostState.showSnackbar(saveFailedMessage)
@@ -178,7 +217,8 @@ fun EventEditScreen(
viewModel.consumeSaveResult()
snackbarHostState.showSnackbar(writeDeniedMessage)
}
SaveUiState.Idle, SaveUiState.Saving, null -> Unit
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
else -> Unit
}
}
@@ -222,6 +262,115 @@ fun EventEditScreen(
)
}
}
if (state?.saveState == SaveUiState.AwaitingScope) {
SaveScopeDialog(
recurrenceChanged = state?.recurrenceChanged == true,
onSelect = viewModel::saveWithScope,
onDismiss = viewModel::consumeSaveResult,
)
}
// The event changed externally (sync) while the form was open (v2.0).
if (state?.saveState is SaveUiState.AwaitingConflict) {
SaveConflictDialog(
onOverwrite = viewModel::saveOverwriting,
onDiscard = close,
onDismiss = viewModel::consumeSaveResult,
)
}
// ...or was deleted underneath us — nothing left to save onto. Closing
// through [onSaved] also pops the detail screen, whose occurrence is gone.
if (state?.saveState == SaveUiState.Gone) {
AlertDialog(
onDismissRequest = {},
title = { Text(stringResource(R.string.event_edit_gone_title)) },
text = { Text(stringResource(R.string.event_edit_gone_body)) },
confirmButton = {
TextButton(onClick = {
viewModel.reset()
onSaved()
}) { Text(stringResource(R.string.dialog_ok)) }
},
)
}
}
/**
* Overwrite-or-discard choice when the event changed underneath an open
* form (no locking; detected by re-reading at save time). "Overwrite" still
* only writes the fields the user edited — external changes to untouched
* fields survive either way. Cancelling returns to the form.
*/
@Composable
private fun SaveConflictDialog(
onOverwrite: () -> Unit,
onDiscard: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_conflict_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.event_edit_conflict_body))
Spacer(Modifier.height(4.dp))
OptionCard(
label = stringResource(R.string.event_edit_conflict_overwrite),
supportingText = stringResource(R.string.event_edit_conflict_overwrite_hint),
onClick = onOverwrite,
)
OptionCard(
label = stringResource(R.string.event_edit_conflict_discard),
supportingText = stringResource(R.string.event_edit_conflict_discard_hint),
onClick = onDiscard,
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
/**
* Scope choice when saving a dirty recurring event: one tap writes just the
* tapped occurrence, it and everything after (series split), or the whole
* series. A changed recurrence rule rules out the single occurrence — an
* exception row can't carry its own rule.
*/
@Composable
private fun SaveScopeDialog(
recurrenceChanged: Boolean,
onSelect: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_recurring_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (!recurrenceChanged) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
)
}
OptionCard(
label = stringResource(R.string.event_delete_option_following),
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { onSelect(RecurringWriteScope.AllEvents) },
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime }
@@ -261,6 +410,7 @@ private fun EventEditContent(
var picker by remember { mutableStateOf<PickerTarget?>(null) }
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
@@ -348,12 +498,15 @@ private fun EventEditContent(
Spacer(Modifier.height(gap))
// Calendar card — tap anywhere to pick the target calendar.
// Calendar card — tap anywhere to pick the target calendar. Editing
// keeps the owning calendar (moving events between calendars is a
// sync-adapter minefield; every stock calendar app locks it too).
EditCard(
icon = Icons.Default.CalendarMonth,
iconContentDescription = stringResource(R.string.event_detail_calendar),
iconTint = accent,
onClick = { showCalendarPicker = true }.takeIf { state.calendars.isNotEmpty() },
onClick = { showCalendarPicker = true }
.takeIf { state.calendars.isNotEmpty() && !state.isEditing },
) {
Text(
text = selectedCalendar?.displayName
@@ -438,6 +591,46 @@ private fun EventEditContent(
}
}
OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) {
Spacer(Modifier.height(gap))
EditCard(
icon = Icons.Default.Repeat,
iconContentDescription = null,
onClick = { showRecurrencePicker = true },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = form.rrule?.let { recurrenceText(it, locale) }
?: AnnotatedString(stringResource(R.string.event_edit_recurrence_none)),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.event_detail_recurrence),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (EventFormProblem.RecurrenceEndsBeforeStart in state.problems) {
Spacer(Modifier.height(2.dp))
Text(
text = stringResource(R.string.event_edit_error_recurrence_ends_before_start),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
OptionalFormSection(visible = EventFormField.Availability in state.visibleFields) {
Spacer(Modifier.height(gap))
EditCard(
@@ -497,7 +690,7 @@ private fun EventEditContent(
}
}
OptionalFormSection(visible = state.hasHiddenFields) {
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
Spacer(Modifier.height(20.dp))
TextButton(
onClick = { showFieldPicker = true },
@@ -561,6 +754,18 @@ private fun EventEditContent(
)
}
if (showRecurrencePicker) {
RecurrencePickerDialog(
current = form.rrule,
startDay = form.start.date.dayOfWeek,
onSelect = { rrule ->
viewModel.setRecurrence(rrule)
showRecurrencePicker = false
},
onDismiss = { showRecurrencePicker = false },
)
}
if (showVisibilityPicker) {
VisibilityPickerDialog(
selected = form.accessLevel,
@@ -574,7 +779,7 @@ private fun EventEditContent(
if (showFieldPicker) {
FieldPickerDialog(
hiddenFields = EventFormField.entries.filterNot { it in state.visibleFields },
hiddenFields = state.hiddenFields,
onSelect = { field ->
viewModel.revealField(field)
showFieldPicker = false
@@ -660,71 +865,17 @@ private fun ReminderPickerDialog(
}
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
// surfaceContainerHighest — the dialog itself sits on
// surfaceContainerHigh, so anything lower vanishes.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
) {
InlineField(
DialogAmountField(
value = amountText,
onValueChange = { text ->
if (text.length <= 3 && text.all(Char::isDigit)) {
amountText = text
}
},
onValueChange = { amountText = it },
placeholder = "10",
textStyle = MaterialTheme.typography.titleMedium,
keyboardType = KeyboardType.Number,
modifier = Modifier
.width(72.dp)
.padding(horizontal = 14.dp, vertical = 12.dp),
)
}
Spacer(Modifier.width(12.dp))
var unitMenuOpen by remember { mutableStateOf(false) }
Box {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
onClick = { unitMenuOpen = true },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(
start = 14.dp,
end = 8.dp,
top = 12.dp,
bottom = 12.dp,
),
) {
Text(
text = stringResource(reminderUnitLabel(unit)),
style = MaterialTheme.typography.titleMedium,
DialogUnitDropdown(
label = stringResource(reminderUnitLabel(unit)),
entries = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
onPick = { unit = ReminderUnit.entries[it] },
)
Spacer(Modifier.width(4.dp))
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
DropdownMenu(
expanded = unitMenuOpen,
onDismissRequest = { unitMenuOpen = false },
) {
ReminderUnit.entries.forEach { entry ->
DropdownMenuItem(
text = { Text(stringResource(reminderUnitLabel(entry))) },
onClick = {
unit = entry
unitMenuOpen = false
},
)
}
}
}
}
}
},
@@ -743,6 +894,351 @@ private fun ReminderPickerDialog(
)
}
/** How a custom recurrence ends; mirrors [RecurrenceEnd] in saveable form. */
private enum class RecurrenceEndMode { Never, Until, Count }
/**
* Recurrence picker, two steps like the reminder picker: the plain
* frequencies as a tappable list (one tap applies and closes), with "Custom"
* switching to an interval-plus-unit editor, weekday toggles (weekly only)
* and an end condition — only that step needs an OK button. A rule the
* simple shape can't express (ordinal BYDAY etc.) stays untouched unless the
* user picks something here.
*/
@Composable
private fun RecurrencePickerDialog(
current: String?,
startDay: DayOfWeek,
onSelect: (String?) -> Unit,
onDismiss: () -> Unit,
) {
val parsed = remember(current) { current?.let(::parseSimpleRecurrence) }
val isPlainPreset = parsed != null && parsed.interval == 1 &&
parsed.end == RecurrenceEnd.Never && parsed.byDays.isEmpty()
var customMode by rememberSaveable { mutableStateOf(false) }
var intervalText by rememberSaveable { mutableStateOf((parsed?.interval ?: 1).toString()) }
var freq by rememberSaveable { mutableStateOf(parsed?.freq ?: RecurrenceFreq.Weekly) }
// The event's start weekday stands in until the user picks days herself.
var daysMask by rememberSaveable {
mutableStateOf(
(parsed?.byDays?.takeIf { it.isNotEmpty() } ?: setOf(startDay)).toMask(),
)
}
var endMode by rememberSaveable {
mutableStateOf(
when (parsed?.end) {
is RecurrenceEnd.Until -> RecurrenceEndMode.Until
is RecurrenceEnd.Count -> RecurrenceEndMode.Count
else -> RecurrenceEndMode.Never
},
)
}
var untilIso by rememberSaveable {
mutableStateOf((parsed?.end as? RecurrenceEnd.Until)?.date?.toString())
}
var countText by rememberSaveable {
mutableStateOf(((parsed?.end as? RecurrenceEnd.Count)?.times ?: 10).toString())
}
var showUntilPicker by rememberSaveable { mutableStateOf(false) }
val locale = currentLocale()
val untilDate = untilIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
val interval = intervalText.toIntOrNull()?.takeIf { it in 1..999 }
val count = countText.toIntOrNull()?.takeIf { it in 1..999 }
val customEnd: RecurrenceEnd? = when (endMode) {
RecurrenceEndMode.Never -> RecurrenceEnd.Never
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) }
}
val customResult: String? = if (interval != null && customEnd != null) {
SimpleRecurrence(
freq = freq,
interval = interval,
end = customEnd,
byDays = if (freq == RecurrenceFreq.Weekly) daysMask.toDaySet() else emptySet(),
).toRRule()
} else {
null
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_detail_recurrence)) },
text = {
if (!customMode) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_edit_recurrence_none),
onClick = { onSelect(null) },
selected = current == null,
)
RecurrenceFreq.entries.forEach { entry ->
OptionCard(
label = stringResource(recurrencePresetLabel(entry)),
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
selected = isPlainPreset && parsed?.freq == entry,
)
}
OptionCard(
label = stringResource(R.string.event_edit_recurrence_custom),
onClick = { customMode = true },
selected = current != null && !isPlainPreset,
labelColor = MaterialTheme.colorScheme.primary,
)
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.event_edit_recurrence_every),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.width(12.dp))
DialogAmountField(
value = intervalText,
onValueChange = { intervalText = it },
placeholder = "1",
)
Spacer(Modifier.width(12.dp))
DialogUnitDropdown(
label = stringResource(recurrenceUnitLabel(freq)),
entries = RecurrenceFreq.entries.map {
stringResource(recurrenceUnitLabel(it))
},
onPick = { freq = RecurrenceFreq.entries[it] },
)
}
if (freq == RecurrenceFreq.Weekly) {
Spacer(Modifier.height(4.dp))
WeekdayToggleRow(
selected = daysMask.toDaySet(),
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
locale = locale,
)
}
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.event_edit_recurrence_ends),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OptionCard(
label = stringResource(R.string.event_edit_recurrence_end_never),
onClick = { endMode = RecurrenceEndMode.Never },
selected = endMode == RecurrenceEndMode.Never,
)
OptionCard(
label = stringResource(R.string.event_edit_recurrence_end_until),
onClick = {
endMode = RecurrenceEndMode.Until
showUntilPicker = true
},
supportingText = untilDate?.let {
remember(it, locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(locale).format(it.toJavaLocalDate())
}
},
selected = endMode == RecurrenceEndMode.Until,
)
OptionCard(
label = stringResource(R.string.event_edit_recurrence_end_count),
onClick = { endMode = RecurrenceEndMode.Count },
selected = endMode == RecurrenceEndMode.Count,
)
if (endMode == RecurrenceEndMode.Count) {
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(
value = countText,
onValueChange = { countText = it },
placeholder = "10",
)
Spacer(Modifier.width(12.dp))
Text(
text = stringResource(R.string.event_edit_recurrence_times),
style = MaterialTheme.typography.titleMedium,
)
}
}
}
}
},
confirmButton = {
// The preset list applies on tap; only the custom step needs OK.
if (customMode) {
TextButton(
enabled = customResult != null,
onClick = { customResult?.let(onSelect) },
) { Text(stringResource(R.string.dialog_ok)) }
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
if (showUntilPicker) {
DatePickerAlert(
initial = untilDate ?: LocalDate.fromEpochDays(
(Clock.System.now().toEpochMilliseconds() / MILLIS_PER_DAY).toInt(),
),
onConfirm = {
untilIso = it.toString()
showUntilPicker = false
},
onDismiss = { showUntilPicker = false },
)
}
}
/**
* One tappable circle per weekday (locale week order), multi-select — the
* BYDAY picks of a weekly rule. Deselecting every day is allowed; the rule
* then falls back to the event's start weekday (provider behaviour).
*/
@Composable
private fun WeekdayToggleRow(
selected: Set<DayOfWeek>,
onToggle: (DayOfWeek) -> Unit,
locale: Locale,
) {
val days = remember(locale) {
val first = WeekFields.of(locale).firstDayOfWeek.toKotlinDayOfWeek()
(0 until 7).map { DayOfWeek(((first.isoDayNumber - 1 + it) % 7) + 1) }
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
days.forEach { day ->
val isSelected = day in selected
Surface(
onClick = { onToggle(day) },
shape = CircleShape,
color = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
},
contentColor = if (isSelected) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurface
},
modifier = Modifier.size(36.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
Text(
text = day.toJavaDayOfWeek().getDisplayName(JavaTextStyle.NARROW, locale),
style = MaterialTheme.typography.labelLarge,
)
}
}
}
}
}
/** Weekday sets travel through rememberSaveable as an ISO-day bitmask. */
private fun DayOfWeek.toMaskBit(): Int = 1 shl (isoDayNumber - 1)
private fun Set<DayOfWeek>.toMask(): Int = fold(0) { acc, day -> acc or day.toMaskBit() }
private fun Int.toDaySet(): Set<DayOfWeek> =
DayOfWeek.entries.filter { this and it.toMaskBit() != 0 }.toSet()
/** Tonal 3-digit number input shared by the custom reminder/recurrence steps. */
@Composable
private fun DialogAmountField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
) {
// surfaceContainerHighest — the dialog itself sits on
// surfaceContainerHigh, so anything lower vanishes.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
) {
InlineField(
value = value,
onValueChange = { text ->
if (text.length <= 3 && text.all(Char::isDigit)) {
onValueChange(text)
}
},
placeholder = placeholder,
textStyle = MaterialTheme.typography.titleMedium,
keyboardType = KeyboardType.Number,
modifier = Modifier
.width(72.dp)
.padding(horizontal = 14.dp, vertical = 12.dp),
)
}
}
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps. */
@Composable
private fun DialogUnitDropdown(
label: String,
entries: List<String>,
onPick: (Int) -> Unit,
) {
var open by remember { mutableStateOf(false) }
Box {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
onClick = { open = true },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(
start = 14.dp,
end = 8.dp,
top = 12.dp,
bottom = 12.dp,
),
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.width(4.dp))
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
entries.forEachIndexed { index, entry ->
DropdownMenuItem(
text = { Text(entry) },
onClick = {
onPick(index)
open = false
},
)
}
}
}
}
private fun recurrencePresetLabel(freq: RecurrenceFreq): Int = when (freq) {
RecurrenceFreq.Daily -> R.string.recurrence_daily
RecurrenceFreq.Weekly -> R.string.recurrence_weekly
RecurrenceFreq.Monthly -> R.string.recurrence_monthly
RecurrenceFreq.Yearly -> R.string.recurrence_yearly
}
private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) {
RecurrenceFreq.Daily -> R.string.recurrence_unit_days
RecurrenceFreq.Weekly -> R.string.recurrence_unit_weeks
RecurrenceFreq.Monthly -> R.string.recurrence_unit_months
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
}
/** One chosen reminder: humanised lead time, remove pinned to the right edge. */
@Composable
private fun ReminderRow(label: String, onRemove: () -> Unit) {
@@ -793,6 +1289,7 @@ private fun fieldLabel(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
}
@@ -801,6 +1298,7 @@ private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
EventFormField.Location -> Icons.Default.Place
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
EventFormField.Reminders -> Icons.Default.Notifications
EventFormField.Recurrence -> Icons.Default.Repeat
EventFormField.Availability -> Icons.Default.EventAvailable
EventFormField.Visibility -> Icons.Default.Lock
}

View File

@@ -4,6 +4,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.EventFormProblem
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
/**
* UI state for the event form (v1.2: create; v1.3 reuses it for edit). Null
@@ -19,13 +20,34 @@ data class EventEditUiState(
val saveState: SaveUiState,
/** Optional sections currently rendered (settings defaults revealed). */
val visibleFields: Set<EventFormField> = emptySet(),
/** True while at least one optional section hides behind "more fields". */
val hasHiddenFields: Boolean = false,
/**
* 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
/**
* The event changed externally (sync) while the form was open; the save
* is parked with its chosen [scope] until the user picks overwrite,
* discard, or cancel.
*/
data class AwaitingConflict(val scope: RecurringWriteScope) : SaveUiState
/** The event was deleted externally while the form was open. */
data object Gone : SaveUiState
data object Saving : SaveUiState
data object Saved : SaveUiState
/** WRITE_CALENDAR was revoked between the tap and the provider call. */

View File

@@ -4,17 +4,23 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EditSnapshot
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.populatedFields
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
@@ -54,13 +60,38 @@ class EventEditViewModel @Inject constructor(
// 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 everything the form saw at load time.
* For recurring events the write scope is chosen at save time; the
* tapped occurrence's [beginMillis]/[endMillis] anchor occurrence-level
* writes and the conflict re-read. [zone] is pinned at load so a device
* timezone change mid-edit can't fake a conflict.
*/
private data class EditTarget(
val eventId: Long,
val snapshot: EditSnapshot,
val beginMillis: Long,
val endMillis: Long,
val zone: TimeZone,
) {
val original: EventForm get() = snapshot.form
}
private data class LocalInputs(
val form: EventForm?,
val saveState: SaveUiState,
val showProblems: Boolean,
val revealed: Set<EventFormField>,
val editTarget: EditTarget?,
)
private data class ExternalInputs(
@@ -70,7 +101,7 @@ class EventEditViewModel @Inject constructor(
)
val state: StateFlow<EventEditUiState?> = combine(
combine(_form, _saveState, _showProblems, _revealed, ::LocalInputs),
combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs),
combine(
repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents } }
@@ -78,7 +109,7 @@ class EventEditViewModel @Inject constructor(
prefs.lastUsedCalendarId,
settingsPrefs.defaultFormFields,
::ExternalInputs,
),
).flowOn(io),
) { local, external ->
val form = local.form ?: return@combine null
val resolvedId = form.calendarId
@@ -92,10 +123,14 @@ class EventEditViewModel @Inject constructor(
problems = if (local.showProblems) resolved.problems() else emptySet(),
saveState = local.saveState,
visibleFields = visibleFields,
hasHiddenFields = visibleFields.size < EventFormField.entries.size,
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),
@@ -123,12 +158,39 @@ class EventEditViewModel @Inject constructor(
_form.value = EventForm(calendarId = null, start = start, end = end)
}
/** Forget the open form; the next [openNew] starts clean. */
/**
* Load an existing event into the form. [beginMillis]/[endMillis] are the
* tapped occurrence's own times, like on the detail screen. No-op while a
* form is open, so user edits survive configuration changes.
*/
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
if (_form.value != null || _editTarget.value != null) return
viewModelScope.launch {
val detail = try {
repository.eventDetail(eventId)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_loadFailed.value = true
return@launch
}
val zone = TimeZone.currentSystemDefault()
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone)
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone)
// Sections holding data must show even when not in the defaults.
_revealed.value = snapshot.form.populatedFields()
_form.value = snapshot.form
}
}
/** Forget the open form; the next [openNew]/[openForEdit] starts clean. */
fun reset() {
_form.value = null
_saveState.value = SaveUiState.Idle
_showProblems.value = false
_revealed.value = emptySet()
_editTarget.value = null
_loadFailed.value = false
}
/** Unfold one optional field, picked in the "more fields" dialog. */
@@ -144,6 +206,9 @@ class EventEditViewModel @Inject constructor(
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())
}
@@ -158,7 +223,12 @@ class EventEditViewModel @Inject constructor(
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. Terminal results land in [saveState]. */
/**
* 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
@@ -167,11 +237,82 @@ class EventEditViewModel @Inject constructor(
_showProblems.value = true
return
}
val target = _editTarget.value
if (target != null && form == target.original) {
// A pristine form saves as a no-op instead of a write.
_saveState.value = SaveUiState.Saved
return
}
if (target != null && target.original.rrule != null) {
_saveState.value = SaveUiState.AwaitingScope
return
}
performSave(form, RecurringWriteScope.AllEvents)
}
/** Finish a save parked in [SaveUiState.AwaitingScope]. */
fun saveWithScope(scope: RecurringWriteScope) {
val current = state.value ?: return
if (current.saveState != SaveUiState.AwaitingScope) return
performSave(current.form, scope)
}
/** Finish a save parked in [SaveUiState.AwaitingConflict], overwriting. */
fun saveOverwriting() {
val current = state.value ?: return
val parked = current.saveState as? SaveUiState.AwaitingConflict ?: return
performSave(current.form, parked.scope, ignoreConflict = true)
}
private fun performSave(
form: EventForm,
scope: RecurringWriteScope,
ignoreConflict: Boolean = false,
) {
val target = _editTarget.value
viewModelScope.launch {
_saveState.value = SaveUiState.Saving
// No locking (plan 03, decision 5): right before writing, re-read
// the event and compare against what the form loaded. An external
// change parks the save in a conflict dialog instead of silently
// clobbering the edited fields.
if (target != null && !ignoreConflict) {
val fresh = try {
repository.eventDetail(target.eventId)
.toEditSnapshot(target.beginMillis, target.endMillis, target.zone)
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
_saveState.value = SaveUiState.Gone
return@launch
} catch (e: Exception) {
// Can't verify — proceed; a real problem fails the write itself.
null
}
if (fresh != null && fresh != target.snapshot) {
_saveState.value = SaveUiState.AwaitingConflict(scope)
return@launch
}
}
_saveState.value = try {
if (target == null) {
repository.createEvent(form)
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
} else {
when (scope) {
RecurringWriteScope.ThisEvent ->
repository.updateOccurrence(target.eventId, target.beginMillis, form)
RecurringWriteScope.ThisAndFollowing ->
repository.updateEventFromOccurrence(
eventId = target.eventId,
beginMillis = target.beginMillis,
original = target.original,
updated = form,
)
RecurringWriteScope.AllEvents ->
repository.updateEvent(target.eventId, target.original, form)
}
}
SaveUiState.Saved
} catch (e: CancellationException) {
throw e

View File

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

View File

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

View File

@@ -10,6 +10,8 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -71,7 +73,7 @@ class MonthViewModel @Inject constructor(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(ym, calendars, instances)
buildState(ym, ws, calendars, instances)
}
}
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
@@ -96,25 +98,64 @@ class MonthViewModel @Inject constructor(
private fun buildState(
ym: YearMonth,
weekStart: DayOfWeek,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): MonthUiState {
if (calendars.isEmpty()) {
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val byDay = instances.groupBy { it.start.toLocalDateTime(zone).date }
.mapValues { (_, evs) ->
DayCellData(
count = evs.size,
swatches = evs.map { it.color }.distinct().take(3),
)
}
return MonthUiState.Success(
month = ym,
today = todayDate,
cells = byDay,
weeks = layoutMonth(ym, weekStart, instances),
)
}
/**
* Split the grid into week rows and resolve each row's events. An event is a
* spanning bar when it's all-day or touches more than one of the row's days;
* everything else is a single-day timed pill. Bars get lanes from the shared
* [layoutAllDay] so a multi-day event stays on one row across the week.
*/
private fun layoutMonth(
ym: YearMonth,
weekStart: DayOfWeek,
instances: List<EventInstance>,
): List<MonthWeek> {
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
val daysInMonth =
java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
val weekCount = (leadOffset + daysInMonth + 6) / 7
return (0 until weekCount).map { row ->
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
val (bars, singles) = weekEvents.partition { ev ->
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
}
val spans = layoutAllDay(bars, days, zone).map { s ->
MonthSpan(
event = s.event,
startCol = s.startCol,
endCol = s.endCol,
lane = s.lane,
continuesLeft = s.event.coversDay(days.first().minus(1, DateTimeUnit.DAY), zone),
continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
)
}
MonthWeek(
days = days,
spans = spans,
timedByDay = days.associateWith { d ->
singles.filter { it.coversDay(d, zone) }.sortedBy { it.start }
},
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
)
}
}
}
/**

View File

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

View File

@@ -6,25 +6,14 @@ import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.CalendarMonth
@@ -34,7 +23,6 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -42,18 +30,13 @@ 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
private val CALENDAR_PERMISSIONS = arrayOf(
@@ -61,15 +44,6 @@ private val CALENDAR_PERMISSIONS = arrayOf(
Manifest.permission.WRITE_CALENDAR,
)
// 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
}
@Composable
fun PermissionScreen(
onGranted: () -> Unit,
@@ -118,7 +92,7 @@ private fun RationaleContent(
onRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
PermissionScaffold(
OnboardingScaffold(
modifier = modifier,
hero = { BrandHero(denied = false) },
actions = {
@@ -131,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,
@@ -147,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,
@@ -161,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),
@@ -189,7 +163,7 @@ private fun DeniedContent(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
PermissionScaffold(
OnboardingScaffold(
modifier = modifier,
hero = { BrandHero(denied = true) },
actions = {
@@ -231,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,6 +47,7 @@ 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
@@ -128,6 +134,13 @@ fun SettingsScreen(
)
}
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()
@@ -249,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
@@ -340,6 +402,7 @@ 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
}

View File

@@ -18,4 +18,6 @@ data class SettingsUiState(
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

@@ -28,13 +28,15 @@ class SettingsViewModel @Inject constructor(
prefs.dynamicColor,
prefs.weekStart,
prefs.defaultFormFields,
) { theme, dynamic, weekStart, formFields ->
prefs.remindersEnabled,
) { theme, dynamic, weekStart, formFields, reminders ->
SettingsUiState(
themeMode = theme,
dynamicColor = dynamic && dynamicColorAvailable,
dynamicColorAvailable = dynamicColorAvailable,
weekStart = weekStart,
defaultFormFields = formFields,
remindersEnabled = reminders,
)
}.stateIn(
scope = viewModelScope,
@@ -57,4 +59,8 @@ class SettingsViewModel @Inject constructor(
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
}
fun setRemindersEnabled(enabled: Boolean) {
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
}
}

View File

@@ -162,7 +162,11 @@ fun WeekScreen(
gesturesEnabled = drawerState.isOpen,
drawerContent = {
CalendarDrawer(
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
currentView = selectedView,
onSelectView = { view ->
onSelectView(view)
scope.launch { drawerState.close() }
},
onSettings = {
onOpenSettings()
scope.launch { drawerState.close() }

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

@@ -45,12 +45,15 @@
<!-- Event-Detail-Screen (S4) -->
<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>
@@ -78,6 +81,31 @@
<string name="reminder_unit_weeks">Wochen</string>
<string name="event_edit_availability">Verfügbarkeit</string>
<string name="event_edit_visibility">Sichtbarkeit</string>
<!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
<string name="event_edit_conflict_title">Termin wurde extern geändert</string>
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
<string name="event_edit_conflict_overwrite">Meine Änderungen speichern</string>
<string name="event_edit_conflict_overwrite_hint">Nur von dir bearbeitete Felder überschreiben die externe Änderung</string>
<string name="event_edit_conflict_discard">Meine Änderungen verwerfen</string>
<string name="event_edit_conflict_discard_hint">Der Termin bleibt, wie er jetzt ist</string>
<string name="event_edit_gone_title">Termin wurde gelöscht</string>
<string name="event_edit_gone_body">Dieser Termin wurde zwischenzeitlich gelöscht, etwa auf einem anderen Gerät. Deine Änderungen können nicht mehr gespeichert werden.</string>
<!-- Termin-Formular — Wiederholungs-Picker (v1.3) -->
<string name="event_edit_recurrence_none">Wiederholt sich nicht</string>
<string name="event_edit_recurrence_custom">Benutzerdefiniert</string>
<string name="event_edit_recurrence_every">Alle</string>
<string name="recurrence_unit_days">Tage</string>
<string name="recurrence_unit_weeks">Wochen</string>
<string name="recurrence_unit_months">Monate</string>
<string name="recurrence_unit_years">Jahre</string>
<string name="event_edit_recurrence_ends">Endet</string>
<string name="event_edit_recurrence_end_never">Nie</string>
<string name="event_edit_recurrence_end_until">An einem Datum</string>
<string name="event_edit_recurrence_end_count">Nach einer Anzahl</string>
<string name="event_edit_recurrence_times">Mal</string>
<string name="event_edit_error_recurrence_ends_before_start">Wiederholung endet vor dem Beginn</string>
<string name="event_availability_busy">Beschäftigt</string>
<string name="event_access_default">Standard</string>
<string name="event_access_public">Öffentlich</string>
@@ -141,10 +169,25 @@
<!-- Geteilte Event-Strings -->
<string name="event_untitled">(Ohne Titel)</string>
<!-- Erinnerungs-Benachrichtigungen (v1.4) -->
<string name="reminder_channel_name">Termin-Erinnerungen</string>
<string name="reminder_channel_description">Benachrichtigungen zu den Erinnerungszeiten deiner Termine</string>
<string name="reminder_onboarding_title">Keinen Termin mehr verpassen</string>
<string name="reminder_onboarding_body">Android zeigt Termin-Erinnerungen nicht von selbst — das muss eine Kalender-App tun. Überlass Calendula den Job.</string>
<string name="reminder_benefit_delivery_title">Erinnerungen, zugestellt</string>
<string name="reminder_benefit_delivery_body">Jede Erinnerung an deinen Terminen kommt pünktlich als Benachrichtigung an.</string>
<string name="reminder_benefit_duplicates_title">Noch eine zweite Kalender-App?</string>
<string name="reminder_benefit_duplicates_body">Wenn eine andere App ebenfalls Erinnerungen zeigt, siehst du sie doppelt — schalte sie dort oder hier ab.</string>
<string name="reminder_benefit_reversible_title">Jederzeit änderbar</string>
<string name="reminder_benefit_reversible_body">Der Schalter liegt in den Einstellungen unter Benachrichtigungen.</string>
<string name="reminder_onboarding_enable_button">Erinnerungen einschalten</string>
<string name="reminder_onboarding_skip_button">Später</string>
<!-- View-Switcher (M1) -->
<string name="view_month">Monat</string>
<string name="view_week">Woche</string>
<string name="view_day">Tag</string>
<string name="view_section">Ansicht</string>
<!-- Kalender-Filter (M3) -->
<string name="filter_title">Kalender</string>
@@ -165,6 +208,9 @@
<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

@@ -46,12 +46,15 @@
<!-- Event detail screen (S4) -->
<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>
@@ -79,6 +82,31 @@
<string name="reminder_unit_weeks">weeks</string>
<string name="event_edit_availability">Availability</string>
<string name="event_edit_visibility">Visibility</string>
<!-- Event form — save conflict (v2.0) -->
<string name="event_edit_conflict_title">Event changed elsewhere</string>
<string name="event_edit_conflict_body">While you were editing, this event was changed — by sync or another app. What should happen to your changes?</string>
<string name="event_edit_conflict_overwrite">Save my changes</string>
<string name="event_edit_conflict_overwrite_hint">Only fields you edited overwrite the outside change</string>
<string name="event_edit_conflict_discard">Discard my changes</string>
<string name="event_edit_conflict_discard_hint">The event stays as it is now</string>
<string name="event_edit_gone_title">Event deleted</string>
<string name="event_edit_gone_body">This event was deleted in the meantime, for example on another device. Your changes can no longer be saved.</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>
@@ -142,10 +170,25 @@
<!-- 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>
<string name="view_day">Day</string>
<string name="view_section">View</string>
<!-- Calendar filter (M3) -->
<string name="filter_title">Calendars</string>
@@ -166,6 +209,9 @@
<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

@@ -198,6 +198,43 @@ class CalendarRepositoryImplTest {
}
}
@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()
@@ -220,6 +257,61 @@ class CalendarRepositoryImplTest {
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 {

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

@@ -71,4 +71,151 @@ class EventWriteMapperTest {
// 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

@@ -20,8 +20,12 @@ internal class FakeCalendarDataSource : CalendarDataSource {
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>()
@@ -36,6 +40,33 @@ internal class FakeCalendarDataSource : CalendarDataSource {
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

View File

@@ -100,6 +100,27 @@ class SettingsPrefsTest {
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

@@ -4,7 +4,9 @@ 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 {
@@ -69,4 +71,170 @@ class EventFormTest {
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,
rowStart: Long = 0L,
rowEnd: Long = 0L,
attendees: List<Attendee> = emptyList(),
): EventDetail = EventDetail(
instance = EventInstance(
instanceId = 1L,
eventId = 1L,
calendarId = 7L,
title = title,
start = Instant.fromEpochMilliseconds(rowStart),
end = Instant.fromEpochMilliseconds(rowEnd),
isAllDay = isAllDay,
color = 0,
location = location,
),
description = description,
organizer = null,
attendees = attendees,
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 `snapshots of an unchanged event are equal`() {
val a = detail().toEditSnapshot(0L, 3_600_000L, berlin)
val b = detail().toEditSnapshot(0L, 3_600_000L, berlin)
assertThat(b).isEqualTo(a)
}
@Test
fun `an external field change makes snapshots differ`() {
val loaded = detail().toEditSnapshot(0L, 3_600_000L, berlin)
val fresh = detail(title = "Stand-up (moved)").toEditSnapshot(0L, 3_600_000L, berlin)
assertThat(fresh).isNotEqualTo(loaded)
}
@Test
fun `an external time move is caught by the row times the form cannot see`() {
// Both snapshots are taken for the same tapped occurrence, so the
// *forms* derive identical times — only rowStart/rowEnd betray the move.
val loaded = detail(rrule = "FREQ=WEEKLY", rowStart = 0L)
.toEditSnapshot(0L, 3_600_000L, berlin)
val fresh = detail(rrule = "FREQ=WEEKLY", rowStart = 86_400_000L)
.toEditSnapshot(0L, 3_600_000L, berlin)
assertThat(fresh.form).isEqualTo(loaded.form)
assertThat(fresh).isNotEqualTo(loaded)
}
@Test
fun `changes the form cannot write do not fake a conflict`() {
val loaded = detail().toEditSnapshot(0L, 3_600_000L, berlin)
val fresh = detail(
attendees = listOf(Attendee("Ada", "ada@example.org", AttendeeStatus.Accepted)),
).toEditSnapshot(0L, 3_600_000L, berlin)
assertThat(fresh).isEqualTo(loaded)
}
@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)
}
}

147
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,147 @@
# Architecture
Calendula is a single-activity Jetpack Compose app layered strictly on top
of Android's calendar provider. This document is the orientation tour: the
principles, the layers, and the three pipelines that are not obvious from
the package list (recurring writes, save conflicts, reminder delivery).
## Principles
1. **`CalendarContract` is the single source of truth.** No app database,
no caching layer, no sync code. Reads query the provider; writes go
straight back to it. Sync is DAVx5's / Google's / the system's job.
2. **Observer-driven UI.** A `ContentObserver` on the provider triggers
re-queries; every screen recomposes from fresh provider state. After a
write, nothing is patched by hand — the provider notifies, the views
refresh. This also covers external changes (sync) for free.
3. **JVM-first testing.** Everything between the UI and the
`ContentResolver` is shaped so it runs as a plain JUnit 5 test: pure
domain logic, cursor-free mappers, a `FakeCalendarDataSource` for
repository tests. Instrumented tests are a last resort.
4. **No network.** The app declares no `INTERNET` permission. Anything that
would need one is an explicit, documented product decision first
(see the roadmap's idea backlog).
## Layers
```mermaid
flowchart TD
subgraph UI ["ui/ — Compose screens + ViewModels"]
Screens["Month / Week / Day\nDetail / Edit / Settings\nPermission + Reminder onboarding"]
end
subgraph Data ["data/"]
Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"]
DS["CalendarDataSource\n(interface + AndroidCalendarDataSource)"]
Prefs["SettingsPrefs / CalendarPrefs\n(DataStore)"]
Rem["reminders/\nReminderAlertStore + ReminderNotifier"]
end
Provider[("CalendarContract\n(system calendar provider)")]
Screens --> Repo
Screens --> Prefs
Repo --> DS
DS --> Provider
Provider -. "ContentObserver tick" .-> Repo
Provider -. "EVENT_REMINDER broadcast" .-> Rem
Rem --> Provider
```
- **`domain/`** — pure Kotlin, no Android imports: models
(`EventInstance`, `EventDetail`, `CalendarSource`, …), the `EventForm`
with validation, `SimpleRecurrence` (RRULE parse/render for the picker),
and `EditSnapshot` (conflict detection). All JVM-tested.
- **`data/calendar/`** — the provider seam. `AndroidCalendarDataSource`
owns every `ContentResolver` call; cursor parsing lives in mappers
(`InstanceMapper`, `EventDetailMapper`, `CalendarMapper`) that read
through a `ColumnReader` abstraction so tests feed them plain maps.
`EventWriteMapper` builds dirty-checked update value sets. `TimeBridge`
converts provider epoch millis ↔ `kotlin.time.Instant`.
- **`data/reminders/`** — the notification pipeline (see below). Kept out
of `data/calendar/` because the receiver needs neither the repository
nor its flows.
- **`data/prefs/`** — DataStore-backed settings (theme, week start, form
field defaults, reminders toggle) and small state (last-used calendar).
- **`ui/`** — one package per screen, each with Screen + ViewModel +
UiState. Shared pieces in `ui/common/` (OptionCard — the app's only
sanctioned selection-dialog style —, recurrence humanizer, FAB column,
drawer, transitions).
## Navigation
There is no navigation library. `MainActivity` hosts `RootScreen`, which
gates on the calendar permission and the one-time reminder onboarding, then
shows `CalendarHost`. `CalendarHost` holds the active view (month/week/day)
plus overlay state for detail, edit, and settings — full-screen overlays
driven by `AnimatedVisibility` with a *held-key* pattern: the last shown
key stays alive through the slide-out so content never flashes empty.
A tapped reminder notification routes through `MainActivity` (`singleTop` +
`onNewIntent`) as an external detail key that `CalendarHost` consumes
exactly like an event tap.
## Recurring writes
The provider's invariants drive the design (learned the hard way, verified
on-device — see plan 03):
- Recurring rows carry `RRULE` + `DURATION` (no `DTEND`); one-off rows
carry `DTEND`.
- *Only this event* → insert a **modified-occurrence exception** via
`CONTENT_EXCEPTION_URI` (the provider clones the series row, so empty
optionals are written as explicit NULLs).
- *This and following* → **series split**: insert the new event first (if
that fails the original is untouched), then truncate the original's
RRULE with `UNTIL`.
- Truncation updates must send the **complete time-column set**
(`DTSTART`/`DURATION`/`RRULE`/`ALL_DAY`/`EVENT_TIMEZONE`) — the provider
regenerates cached instances only from the values carried by the update
itself; an RRULE-only update leaves stale instances behind.
- `UNTIL` is written as the local end of the previous day expressed in
UTC, so zones ahead of UTC can't leak an extra occurrence.
- All-day events are normalised to UTC midnights with an exclusive end.
## Save conflicts
No locking. `openForEdit` keeps an `EditSnapshot` — the prefilled form
*plus the raw Events-row times* (the form derives its times from the tapped
occurrence, so a remotely moved event would otherwise be invisible to it).
Right before writing, the event is re-read and snapshots compared: a
mismatch parks the save in an overwrite/discard dialog; a vanished event
informs and closes. Overwrite still writes only dirty fields, so external
changes to untouched fields survive either way. Fields the form cannot
write (attendees, status, reminder methods) are excluded so sync noise
can't fake a conflict.
## Reminder delivery
The provider schedules reminder alarms (for `METHOD_ALERT` rows only) and
broadcasts `EVENT_REMINDER` — but posts no notification; a calendar app
must (the Etar model):
```mermaid
sequenceDiagram
participant P as CalendarProvider
participant R as EventReminderReceiver
participant S as ReminderAlertStore
participant N as ReminderNotifier
P->>R: EVENT_REMINDER broadcast (manifest receiver, exported)
R->>S: dueAlerts(now) — CalendarAlerts: SCHEDULED, alarmTime ≤ now
S-->>R: due alerts
R->>N: post(alert) — one notification per alert, tag = alert id
R->>S: markFired(ids) — best effort, needs WRITE_CALENDAR
```
Posting happens before marking: a crash in between re-posts silently (same
tag + `setOnlyAlertOnce`) rather than losing a reminder. Swiped
notifications never return because `FIRED` rows are never re-queried.
Deliberately absent until real devices prove it necessary: own alarm
scheduling, `BOOT_COMPLETED`, snooze/dismiss actions, battery-exemption
prompts.
## Testing
JUnit 5 + Truth + Turbine on the JVM. The seams that make it work:
`CalendarDataSource` is faked (`FakeCalendarDataSource` records writes),
mappers parse `ColumnReader`/plain maps instead of cursors, domain logic
(recurrence, validation, snapshots, write-value building) is pure. CI
(Gitea Actions) runs `lint test assembleDebug` on every push; release tags
additionally build, sign, and publish to the self-hosted F-Droid repo.

20
docs/README.md Normal file
View File

@@ -0,0 +1,20 @@
# Documentation map
Where to look for what:
| Document | What it is |
|---|---|
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | Orientation tour: principles, layers, navigation, recurring-write / conflict / reminder pipelines, testing |
| [`../CHANGELOG.md`](../CHANGELOG.md) | Release history (Keep a Changelog, SemVer) |
| [`../.planning/ROADMAP.md`](../.planning/ROADMAP.md) | Living roadmap: shipped milestones, current scope, idea backlog |
| [`../.planning/PROJECT.md`](../.planning/PROJECT.md) | What the project is, stack, naming, infrastructure |
| [`../.planning/REQUIREMENTS.md`](../.planning/REQUIREMENTS.md) | Requirement checklist per milestone |
| [`../.planning/STATE.md`](../.planning/STATE.md) | Snapshot of where development currently stands |
| [`superpowers/specs/`](superpowers/specs/) | The original design spec (2026-06-08) — historical record, not updated |
| [`superpowers/plans/`](superpowers/plans/) | Per-milestone implementation plans with task checklists — historical record of how each slice was built, including provider lessons learned |
| [`../fdroid-metadata/`](../fdroid-metadata/) | F-Droid/fastlane store metadata: descriptions, icon, screenshots (DE + EN) |
Conventions: plans and specs under `superpowers/` are point-in-time
artifacts of the agentic workflow that built each milestone — they get
status updates but are never rewritten. The `.planning/` files are living
documents and should stay current.

101
docs/RELEASING.md Normal file
View File

@@ -0,0 +1,101 @@
# Releasing Calendula
Calendula is distributed through a self-hosted F-Droid repository. Every
release is built, signed, and published automatically by
`.gitea/workflows/release.yaml` when a version tag is pushed.
## Versioning — the git tag is the single source of truth
A release is defined by its tag, `vMAJOR.MINOR.PATCH` (e.g. `v2.1.0`). At
release time the workflow derives both Gradle fields from the tag:
- `versionName` = the tag without the leading `v` (`2.1.0`)
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`2.1.0``20100`)
So `MINOR` and `PATCH` each have room for 099. The values committed in
`app/build.gradle.kts` are only the dev/local default — CI overwrites them
from the tag. Keep the committed `versionCode`/`versionName` matching the
**latest released tag** so local builds are sanely versioned; the published
value always comes from the tag.
Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`→20000.
## Cutting a release
1. Move the `## [Unreleased]` section of `CHANGELOG.md` under a new
`## [X.Y.Z] — <date>` heading (Keep a Changelog format). The text between
that heading and the next `## [` becomes both the Gitea release notes and
the F-Droid per-version changelog.
2. Optionally bump the committed `versionCode`/`versionName` in
`app/build.gradle.kts` to match the new version (keeps local builds tidy).
3. Commit, then tag and push:
```bash
git tag vX.Y.Z
git push origin vX.Y.Z
```
4. The push triggers the release workflow. **Hold UI releases for on-device
review and explicit go-ahead before tagging.**
## What the pipeline does
`release.yaml` has three jobs:
- **ci** — unit tests + a debug assemble (sanity).
- **build-and-deploy** — derives the version, builds & signs the release APK
with the app key, copies it into the F-Droid repo, generates the per-version
changelog, re-signs the F-Droid index with the **repo key**, uploads
`repo/` + `metadata/` to the box, and attaches the R8 `mapping.txt` to the
Gitea release (best-effort).
- **gitea-release** — creates/updates the Gitea release carrying the tag's
CHANGELOG section as notes. Gated on `ci` only (not the deploy) so notes
publish even if the F-Droid upload hiccups.
### Manual re-sign / recovery
A manual `workflow_dispatch` of the release workflow **from a branch** (not a
tag) runs a **re-sign-only** path: it skips the APK build and just re-signs
the existing F-Droid index with the configured repo key and re-uploads. Use
this for key rotation or repo recovery without publishing a new app version.
## Secrets (Gitea → repo Settings → Actions → Secrets)
| Secret | Purpose |
| --- | --- |
| `KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS` | **App** signing key — signs the APK. Losing it means existing installs can't be updated. |
| `FDROID_KEYSTORE_BASE64` | **F-Droid repo** signing key (`keystore.p12`, base64). Signs the repo index. |
| `FDROID_CONFIG_BASE64` | F-Droid `config.yml` (base64) — repo metadata + keystore passwords. |
| `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | Upload target for the F-Droid repo. |
| `GITHUB_TOKEN` | Provided by Gitea Actions; used to create the release + attach assets. |
The two keys are independent: the **app key** signs APKs; the **repo key**
signs the index (its fingerprint is what users pin). Neither key nor the
F-Droid `config.yml` is ever uploaded to the server — they live only in CI
secrets and are reconstructed in-runner. If `FDROID_KEYSTORE_BASE64` /
`FDROID_CONFIG_BASE64` are unset the workflow **fails loudly** rather than
minting a new repo key (which would break every user's pinned fingerprint).
## Key custody & recovery
- **Offline backups** of both keys (and passwords) live in a password manager.
These are the only safe copies — losing them is unrecoverable.
- **App key lost** → no existing install can be updated again; you'd have to
ship a new app under a new applicationId.
- **Repo key lost or compromised** → rotate it, publish the new fingerprint in
the README, and have users remove + re-add the repo. To rotate: generate a
new `keystore.p12` + `config.yml`, set them as the `FDROID_*` secrets, update
the README fingerprint, and run the manual re-sign dispatch above.
## F-Droid repo
- URL: `https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo`
- Fingerprint (current): `C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425`
- Served from the Hetzner storage box. **nginx serves only `…/fdroid/repo/`** —
the working dir (key, config, metadata) sits above it and must never be
web-reachable. After any webserver change, verify `keystore.p12` and
`config.yml` return 404 while `repo/index-v2.json` returns 200.
## Crash deobfuscation
Each release attaches `mapping-<version>.txt.gz` (the R8 mapping) to its Gitea
release. To deobfuscate a user stacktrace, download the mapping for that
version and run it through `retrace`.

View File

@@ -47,8 +47,8 @@ Domain bleibt pure Kotlin.
|---|---|---|
| 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 | offen |
| v2.0 | Quick-Add, Occurrence-Edit, Konflikt-Dialog, Polish-Pass, Release | offen |
| v1.3 | Edit: Formular wiederverwendet, Serien-Edit, Reminder-Edit, einfacher Recurrence-Picker | ausgeliefert (v1.3.0, 2026-06-11) |
| v2.0 | Konflikt-Dialog, Polish-Pass (Store-Copy, Screenshots), Release | ausgeliefert (v2.0.0, 2026-06-11) |
## v1.1 — Write-Fundament + Delete
@@ -95,15 +95,110 @@ Domain bleibt pure Kotlin.
- [x] `insertEvent(form): Long` im DataSource; `EventWriteMapper` (JVM-testbar)
normalisiert all-day auf UTC-Mitternächte mit exklusivem DTEND
## v1.3 — Edit (Skizze)
## v1.3 — Edit
- Formular lädt `EventDetail`, Dirty-Check, `update` auf Events-Row
- Reminder hinzufügen/entfernen (`Reminders`-Insert/Delete)
- Einfacher Recurrence-Picker (FREQ + INTERVAL + UNTIL/COUNT)
**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)
## v2.0 — Abschluss (Skizze)
**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)
- Quick-Add-Sheet (Titel + Zeit, Rest Defaults)
- Occurrence-Edit (Exception mit geänderten Werten)
- Konflikt-Dialog beim Speichern
- Changelog, F-Droid-Metadaten, Release-Tag
**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 (Scope-Recut 2026-06-11, nach v1.4)
- ~~Quick-Add-Sheet (Titel + Zeit, Rest Defaults)~~ — **gestrichen**: das
Formular öffnet bereits vorbefüllt (sichtbarer Tag, zuletzt benutzter
Kalender, optionale Felder versteckt); der Sheet spart nur einen
Screen-Übergang und kostet eine zweite Create-Surface. Nur bei
Praxis-Feedback wieder aufnehmen
- ~~Occurrence-Edit (Exception mit geänderten Werten)~~ — schon in v1.3
ausgeliefert (vorgezogen)
- [x] Konflikt-Dialog beim Speichern (Leitentscheidung 5): `EditSnapshot`
(Formular + rohe Row-Zeiten) wird beim Laden gemerkt und vor dem
Schreiben gegen einen frischen Read verglichen; Abweichung parkt den
Save in `AwaitingConflict` (Überschreiben/Verwerfen/Abbrechen,
OptionCard-Stil), gelöschtes Event → `Gone`-Dialog. "Überschreiben"
schreibt weiterhin nur dirty Felder
- Kalender-Wechsel beim Bearbeiten → v3-Backlog (copy+delete-Modell)
- [x] Polish: F-Droid-Description + README auf Write-Support + Reminder
aktualisiert (DE+EN)
- [x] F-Droid-Screenshots (de-DE + en-US, je 6: Woche/Monat/Tag/Detail/
Formular/Onboarding) — mit Demo-Kalendern auf dem Gerät aufgenommen
- [x] Changelog, Release-Tag v2.0.0 (ausgeliefert 2026-06-11 — Milestone 2
damit abgeschlossen)

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

View File

@@ -1,12 +1,17 @@
Calendula ist eine moderne, quelloffene Kalender-App für Android. Sie liest
direkt aus dem System-Kalender-Provider — jede Quelle, die mit deinem Gerät
synchronisiert ist (Nextcloud über DAVx5, Google, lokal, WebCal-Subscriptions)
erscheint automatisch.
Calendula ist eine moderne, quelloffene Kalender-App für Android. Sie
arbeitet direkt auf dem System-Kalender-Provider — jede Quelle, die mit
deinem Gerät synchronisiert ist (Nextcloud über DAVx5, Google, lokal,
WebCal-Subscriptions), erscheint automatisch, und deine Änderungen
synchronisieren auf demselben Weg zurück.
Termine erstellen, bearbeiten und löschen — auch wiederkehrende, mit
wählbarer Reichweite (nur dieser Termin / dieser und alle folgenden / ganze
Serie) und einem einfachen Wiederholungs-Picker. Erinnerungen stellt
Calendula selbst als Benachrichtigung zu — ein Tipp darauf öffnet den
Termin.
Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend,
mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen.
V1 ist read-only. Erstellen, Bearbeiten und Löschen von Events kommt mit V2.
Datenschutz: keinerlei Telemetrie, kein Tracking, kein Netzwerkzugriff — deine
Daten bleiben auf dem Gerät.
Datenschutz: keinerlei Telemetrie, kein Tracking, kein Netzwerkzugriff —
deine Daten bleiben auf dem Gerät.

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

View File

@@ -1,11 +1,15 @@
Calendula is a modern, open-source calendar app for Android. It reads from
the system calendar provider, so any source synced to your device — Nextcloud
via DAVx5, Google, local, WebCal subscriptions — shows up automatically.
Calendula is a modern, open-source calendar app for Android. It works
directly on the system calendar provider, so any source synced to your
device — Nextcloud via DAVx5, Google, local, WebCal subscriptions — shows up
automatically, and changes you make sync back the same way.
The differentiator is the design: real Material 3 Expressive throughout, with
dynamic color, expressive motion, and expressive shapes.
Create, edit and delete events, including recurring events with scoped
changes (only this event / this and all following / the whole series) and a
simple repeat picker. Calendula also delivers your event reminders as
notifications — tap one and you're on the event.
V1 is read-only. Event creation, editing, and deletion are planned for V2.
The differentiator is the design: real Material 3 Expressive throughout,
with dynamic color, expressive motion, and expressive shapes.
Privacy: zero telemetry, no analytics, no network access — your data never
leaves the device.

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB