Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15fb76005c | |||
| c27a645c19 | |||
| 21e7b1ff91 | |||
| 31163da868 | |||
| 9a1903e6ed | |||
| f990af1cb0 | |||
| e5be5f1ae5 | |||
| 54aed73726 | |||
| 82c3e1d605 | |||
| e5b523e907 |
@@ -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
4
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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.1–v2.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
|
||||
|
||||
|
||||
@@ -2,39 +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)~~ —
|
||||
**reversed:** Calendula targets sole-calendar-app users, so no other app
|
||||
posts reminder notifications. We post them ourselves (Etar model). Planned
|
||||
for v1.4 — see `ROADMAP.md`.
|
||||
- Tablet/foldable-specific layouts
|
||||
- Locations & People ideas (contact picker, OSM autocomplete) — see
|
||||
`ROADMAP.md` idea backlog, undecided
|
||||
- iOS support (Android-only by design)
|
||||
|
||||
## Constraints
|
||||
|
||||
@@ -117,3 +117,49 @@ Deliberately deferred (add only if needed):
|
||||
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).
|
||||
|
||||
@@ -4,13 +4,10 @@
|
||||
|
||||
## Status
|
||||
|
||||
**Milestone:** v2.0 — Write support (milestone 2, in progress)
|
||||
**Phase:** v1.3.0 (edit event) shipped 2026-06-11 after four on-device
|
||||
review rounds (BYDAY toggles, scoped recurring writes, scope-at-save flip,
|
||||
stale-instances split bugfix). Milestone 2 runs in four slices
|
||||
(`docs/superpowers/plans/2026-06-11-03-write-support.md`); v2.0 (quick-add,
|
||||
conflict dialog, polish) is the remaining slice, v1.4 (reminder
|
||||
notifications) comes first.
|
||||
**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
|
||||
|
||||
@@ -62,10 +59,22 @@ notifications) comes first.
|
||||
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.4 — reminder notifications (essential for sole-app use): `EVENT_REMINDER`
|
||||
receiver + notification channel, `POST_NOTIFICATIONS`, onboarding step with
|
||||
default-on toggle + duplicate-reminder warning (Etar model)
|
||||
2. v2.0 — quick-add sheet, conflict dialog, polish pass, milestone release
|
||||
3. Monitor the F-Droid build/publish for v1.1.0 – v1.3.0
|
||||
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
|
||||
|
||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -7,6 +7,27 @@ 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
|
||||
|
||||
133
README.md
133
README.md
@@ -1,47 +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
|
||||
<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
|
||||
- Full event details — attendees, reminders, recurrence, availability, and more
|
||||
- Create, edit, and delete events — recurring events with scoped writes
|
||||
(only this event / this and all following / whole series) and a simple
|
||||
recurrence picker
|
||||
- Reminder notifications, delivered by Calendula itself (tap opens the event)
|
||||
- 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">
|
||||
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/02-month.png" width="19%" alt="Month view">
|
||||
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">
|
||||
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/05-edit.png" width="19%" alt="Event form">
|
||||
<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>
|
||||
|
||||
```bash
|
||||
# Build debug APK
|
||||
./gradlew assembleDebug
|
||||
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.
|
||||
|
||||
# Run unit tests
|
||||
./gradlew test
|
||||
## ✨ Features
|
||||
|
||||
# Run lint
|
||||
./gradlew lint
|
||||
**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
|
||||
```
|
||||
|
||||
If your default JDK is something other than 17, set `JAVA_HOME` explicitly:
|
||||
<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
|
||||
JAVA_HOME=/path/to/jdk-17 ./gradlew assembleDebug
|
||||
./gradlew assembleDebug # debug APK
|
||||
./gradlew test # JVM unit tests
|
||||
./gradlew lint # Android lint
|
||||
```
|
||||
|
||||
## License
|
||||
If your default JDK is not 17, set `JAVA_HOME` explicitly.
|
||||
|
||||
## 🏗 Architecture
|
||||
|
||||
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.1–v2.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
|
||||
|
||||
@@ -23,8 +23,13 @@ android {
|
||||
applicationId = "de.jeanlucmakiola.calendula"
|
||||
minSdk = 29
|
||||
targetSdk = 36
|
||||
versionCode = 13
|
||||
versionName = "2.0.0"
|
||||
// The git tag is the single source of truth for released builds: at
|
||||
// release time .gitea/workflows/release.yaml derives both fields from
|
||||
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
|
||||
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
|
||||
// default; keep them matching the latest released tag. See docs/RELEASING.md.
|
||||
versionCode = 20100
|
||||
versionName = "2.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -109,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
|
||||
@@ -131,7 +131,6 @@ class EventEditViewModel @Inject constructor(
|
||||
resolved.rrule != local.editTarget.original.rrule,
|
||||
)
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
|
||||
@@ -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) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
/**
|
||||
* 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 DayCard(
|
||||
date: LocalDate,
|
||||
isToday: Boolean,
|
||||
data: DayCellData?,
|
||||
onClick: () -> Unit,
|
||||
private fun MonthWeekRow(
|
||||
week: MonthWeek,
|
||||
today: LocalDate,
|
||||
month: YearMonth,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val todayPrefix = stringResource(R.string.month_a11y_today_prefix)
|
||||
val cellLabel = buildString {
|
||||
if (isToday) append(todayPrefix).append(", ")
|
||||
append(date.year).append('-')
|
||||
append((date.month.ordinal + 1).toString().padStart(2, '0')).append('-')
|
||||
append(date.day.toString().padStart(2, '0'))
|
||||
data?.let { append(", ").append(it.count).append(" Events") }
|
||||
}
|
||||
val dark = isSystemInDarkTheme()
|
||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
||||
|
||||
// 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",
|
||||
)
|
||||
BoxWithConstraints(modifier) {
|
||||
val colW = maxWidth / 7
|
||||
|
||||
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,
|
||||
// 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,
|
||||
),
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
)
|
||||
}
|
||||
.semantics { contentDescription = cellLabel },
|
||||
) {
|
||||
Column(
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
week.days.forEach { d ->
|
||||
DayNumberCell(
|
||||
date = d,
|
||||
isToday = d == today,
|
||||
inMonth = d.month == month.month && d.year == month.year,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Breathing room between the day number (and today's circle) and the
|
||||
// first event row.
|
||||
Spacer(Modifier.height(DAY_NUMBER_GAP))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 4.dp, bottom = 2.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
.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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayNumberCell(
|
||||
date: LocalDate,
|
||||
isToday: Boolean,
|
||||
inMonth: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.height(DAY_NUMBER_HEIGHT),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isToday) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(DAY_NUMBER_HEIGHT)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
style = MaterialTheme.typography.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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -187,6 +187,7 @@
|
||||
<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>
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
<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>
|
||||
|
||||
147
docs/ARCHITECTURE.md
Normal file
147
docs/ARCHITECTURE.md
Normal 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
20
docs/README.md
Normal 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
101
docs/RELEASING.md
Normal 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 0–99. 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`.
|
||||
Reference in New Issue
Block a user