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:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -121,7 +121,12 @@ jobs:
|
|||||||
$SUDO apk add --no-cache jq
|
$SUDO apk add --no-cache jq
|
||||||
fi
|
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
|
- name: Set version from git tag
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
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/versionName = \".*\"/versionName = \"$VERSION\"/" app/build.gradle.kts
|
||||||
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
|
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
|
||||||
grep -E 'versionName|versionCode' 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
|
- name: Setup Android Keystore
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
env:
|
env:
|
||||||
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
@@ -155,6 +164,7 @@ jobs:
|
|||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
- name: Build release APK
|
- name: Build release APK
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
run: ./gradlew assembleRelease
|
run: ./gradlew assembleRelease
|
||||||
|
|
||||||
- name: Setup F-Droid Server Tools
|
- name: Setup F-Droid Server Tools
|
||||||
@@ -165,29 +175,48 @@ jobs:
|
|||||||
$SUDO apt-get install -y sshpass python3-pip
|
$SUDO apt-get install -y sshpass python3-pip
|
||||||
pip3 install --break-system-packages --upgrade fdroidserver
|
pip3 install --break-system-packages --upgrade fdroidserver
|
||||||
|
|
||||||
- name: Initialize or fetch F-Droid Repository
|
- name: Fetch existing F-Droid repo from Hetzner
|
||||||
env:
|
env:
|
||||||
HOST: ${{ secrets.HETZNER_HOST }}
|
HOST: ${{ secrets.HETZNER_HOST }}
|
||||||
USER: ${{ secrets.HETZNER_USER }}
|
USER: ${{ secrets.HETZNER_USER }}
|
||||||
PASS: ${{ secrets.HETZNER_PASS }}
|
PASS: ${{ secrets.HETZNER_PASS }}
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=20"
|
||||||
mkdir -p fdroid
|
mkdir -p fdroid
|
||||||
sshpass -p "$PASS" sftp -o StrictHostKeyChecking=no "$USER@$HOST" <<'SFTP'
|
# Pull only the published repo/ (all apps' APKs), any per-app
|
||||||
-mkdir dev
|
# metadata, and the repo icon — enough to rebuild the index without
|
||||||
-mkdir dev/fdroid
|
# dropping the other apps. The signing key is deliberately NOT pulled
|
||||||
-mkdir dev/fdroid/repo
|
# from the box; it comes from CI secrets in the next step so it never
|
||||||
SFTP
|
# has to live in the web-served tree.
|
||||||
sshpass -p "$PASS" scp -o StrictHostKeyChecking=no -r "$USER@$HOST:dev/fdroid/." fdroid/ || (cd fdroid && fdroid init)
|
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: |
|
run: |
|
||||||
cd fdroid
|
set -euo pipefail
|
||||||
mkdir -p repo/icons
|
# Fail loudly if the repo key is not configured. NEVER auto-generate
|
||||||
if [ ! -f keystore.p12 ]; then
|
# one: a fresh key changes the repo fingerprint and breaks every
|
||||||
fdroid update --create-key
|
# 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
|
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
|
- name: Copy new APK to repo
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
mkdir -p fdroid/repo
|
mkdir -p fdroid/repo
|
||||||
@@ -203,12 +232,33 @@ jobs:
|
|||||||
mkdir -p fdroid/metadata
|
mkdir -p fdroid/metadata
|
||||||
cp -r fdroid-metadata/* 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
|
- name: Generate F-Droid Index
|
||||||
run: |
|
run: |
|
||||||
cd fdroid
|
cd fdroid
|
||||||
fdroid update -c
|
fdroid update -c
|
||||||
|
|
||||||
- name: Upload Repo to Hetzner
|
- name: Upload repo/ to Hetzner
|
||||||
env:
|
env:
|
||||||
HOST: ${{ secrets.HETZNER_HOST }}
|
HOST: ${{ secrets.HETZNER_HOST }}
|
||||||
USER: ${{ secrets.HETZNER_USER }}
|
USER: ${{ secrets.HETZNER_USER }}
|
||||||
@@ -219,6 +269,113 @@ jobs:
|
|||||||
sshpass -p "$PASS" sftp $SSH_OPTS "$USER@$HOST" <<'SFTP'
|
sshpass -p "$PASS" sftp $SSH_OPTS "$USER@$HOST" <<'SFTP'
|
||||||
-mkdir dev
|
-mkdir dev
|
||||||
-mkdir dev/fdroid
|
-mkdir dev/fdroid
|
||||||
-mkdir dev/fdroid/repo
|
|
||||||
SFTP
|
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
|
# Keystore files
|
||||||
*.jks
|
*.jks
|
||||||
*.keystore
|
*.keystore
|
||||||
|
*.p12
|
||||||
/key.properties
|
/key.properties
|
||||||
|
|
||||||
# Google Services (e.g. APIs or Firebase)
|
# Google Services (e.g. APIs or Firebase)
|
||||||
@@ -50,8 +51,7 @@ google-services.json
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# F-Droid local artifacts (the pipeline generates them in CI)
|
# F-Droid local artifacts (the pipeline generates them in CI)
|
||||||
fdroid/repo/
|
/fdroid/
|
||||||
fdroid/keystore.p12
|
|
||||||
|
|
||||||
# KSP
|
# KSP
|
||||||
.ksp/
|
.ksp/
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
## What This Is
|
## What This Is
|
||||||
|
|
||||||
A modern Material 3 Expressive Android calendar app, read-only V1. Lives
|
A modern Material 3 Expressive Android calendar app. Lives entirely on top
|
||||||
entirely on top of Android's `CalendarContract` — any calendar synced to the
|
of Android's `CalendarContract` — any calendar synced to the device (CalDAV
|
||||||
device (CalDAV via DAVx5, Google, local, WebCal, …) shows up automatically.
|
via DAVx5, Google, local, WebCal, …) shows up automatically; creating,
|
||||||
The differentiator is visual: real Material 3 Expressive design that no
|
editing, and deleting writes straight back, and reminders are delivered by
|
||||||
existing FOSS calendar app delivers.
|
the app itself (Etar model). The differentiator is visual: real Material 3
|
||||||
|
Expressive design that no existing FOSS calendar app delivers.
|
||||||
|
|
||||||
## Core Value
|
## Core Value
|
||||||
|
|
||||||
@@ -15,8 +16,10 @@ re-inventing the calendar sync stack — leave that to DAVx5 and the system.
|
|||||||
|
|
||||||
## Current Milestone
|
## Current Milestone
|
||||||
|
|
||||||
**v0.1 — Foundation & CI:** Buildable Android project scaffold with theme,
|
Milestones 1 (read, v1.0) and 2 (write support, v1.1–v2.0.0 incl. reminder
|
||||||
icon, i18n, Hilt, DataStore, green CI.
|
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
|
## 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
|
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.
|
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. No `INTERNET` permission —
|
||||||
|
any feature that would need one is an explicit product decision first.
|
||||||
Android-only (minSdk 29, targetSdk 36). No iOS.
|
|
||||||
|
|
||||||
## Naming
|
## Naming
|
||||||
|
|
||||||
|
|||||||
@@ -2,39 +2,43 @@
|
|||||||
|
|
||||||
See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
|
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)
|
- [x] Foundation & CI infrastructure — v0.1.0 (2026-06-08)
|
||||||
- Foundation & CI infrastructure — v0.1.0 (2026-06-08)
|
|
||||||
|
|
||||||
### Active (V1)
|
|
||||||
|
|
||||||
- [x] Foundation & CI infrastructure
|
|
||||||
- [x] Data Layer over `CalendarContract`
|
- [x] Data Layer over `CalendarContract`
|
||||||
- [x] Permission flow (`READ_CALENDAR`)
|
- [x] Permission flow (`READ_CALENDAR`)
|
||||||
- [ ] Month view (S1)
|
- [x] Month view (S1)
|
||||||
- [ ] Week view (S2)
|
- [x] Week view (S2)
|
||||||
- [ ] Day view (S3)
|
- [x] Day view (S3)
|
||||||
- [ ] Event Detail Sheet (S4)
|
- [x] Event Detail Sheet (S4) — became a full screen, plus full event read (v0.6)
|
||||||
- [ ] Multi-Calendar Filter (M3)
|
- [x] Multi-Calendar Filter (M3)
|
||||||
- [x] Today button (M2) — shipped v0.5; Jump-to-Date **cut from scope**
|
- [x] Today button (M2) — shipped v0.5; Jump-to-Date **cut from scope**
|
||||||
- [ ] View-Switcher (M1)
|
- [x] View-Switcher (M1)
|
||||||
- [ ] Settings screen (M4)
|
- [x] Settings screen (M4)
|
||||||
- [ ] Empty / no-permission / no-calendars states
|
- [x] Empty / no-permission / no-calendars states
|
||||||
- [ ] German + English localization
|
- [x] German + English localization
|
||||||
- [ ] Loading/Failure/Success states per screen (architectural pattern)
|
- [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
|
- Home-screen widget
|
||||||
- Full-text search
|
- 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
|
- Tablet/foldable-specific layouts
|
||||||
|
- Locations & People ideas (contact picker, OSM autocomplete) — see
|
||||||
|
`ROADMAP.md` idea backlog, undecided
|
||||||
- iOS support (Android-only by design)
|
- iOS support (Android-only by design)
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|||||||
@@ -117,3 +117,49 @@ Deliberately deferred (add only if needed):
|
|||||||
consequences warning — deferred from v2.0, see above)
|
consequences warning — deferred from v2.0, see above)
|
||||||
|
|
||||||
Order is indicative — community feedback after V1 may re-prioritize.
|
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
|
## Status
|
||||||
|
|
||||||
**Milestone:** v2.0 — Write support (milestone 2, in progress)
|
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11.
|
||||||
**Phase:** v1.3.0 (edit event) shipped 2026-06-11 after four on-device
|
**Phase:** between milestones. Next: v3.0 (power-user features) and the
|
||||||
review rounds (BYDAY toggles, scoped recurring writes, scope-at-save flip,
|
go/no-go on the "Locations & People" idea backlog (`ROADMAP.md`). Docs
|
||||||
stale-instances split bugfix). Milestone 2 runs in four slices
|
pass done (ARCHITECTURE.md, README overhaul, planning docs refreshed).
|
||||||
(`docs/superpowers/plans/2026-06-11-03-write-support.md`); v2.0 (quick-add,
|
|
||||||
conflict dialog, polish) is the remaining slice, v1.4 (reminder
|
|
||||||
notifications) comes first.
|
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
@@ -62,10 +59,22 @@ notifications) comes first.
|
|||||||
recurring saves park in `SaveUiState.AwaitingScope`, a changed rule drops
|
recurring saves park in `SaveUiState.AwaitingScope`, a changed rule drops
|
||||||
the "only this event" option
|
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
|
## Next
|
||||||
|
|
||||||
1. v1.4 — reminder notifications (essential for sole-app use): `EVENT_REMINDER`
|
1. Decide the "Locations & People" go/no-go (INTERNET permission question)
|
||||||
receiver + notification channel, `POST_NOTIFICATIONS`, onboarding step with
|
— see `ROADMAP.md` idea backlog
|
||||||
default-on toggle + duplicate-reminder warning (Etar model)
|
2. v3.0 scoping: widget, full-text search, tablet layouts, ICS import,
|
||||||
2. v2.0 — quick-add sheet, conflict dialog, polish pass, milestone release
|
calendar-move
|
||||||
3. Monitor the F-Droid build/publish for v1.1.0 – v1.3.0
|
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]
|
## [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
|
## [2.0.0] — 2026-06-11
|
||||||
|
|
||||||
### Added
|
### 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
|
<h1>Calendula</h1>
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
<p>
|
||||||
- Full event details — attendees, reminders, recurrence, availability, and more
|
<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>
|
||||||
- Create, edit, and delete events — recurring events with scoped writes
|
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
|
||||||
(only this event / this and all following / whole series) and a simple
|
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
|
||||||
recurrence picker
|
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
|
||||||
- Reminder notifications, delivered by Calendula itself (tap opens the event)
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a>
|
||||||
- Multi-calendar visibility toggle
|
</p>
|
||||||
- Material You Dynamic Color (Android 12+)
|
|
||||||
- Light/Dark theme follows system
|
|
||||||
- German + English UI
|
|
||||||
|
|
||||||
## 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
|
Calendula is named after the flower whose name — like the word *calendar* —
|
||||||
# Build debug APK
|
comes from the Latin *kalendae*, the first day of the month. It lives
|
||||||
./gradlew assembleDebug
|
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
|
## ✨ Features
|
||||||
./gradlew test
|
|
||||||
|
|
||||||
# Run lint
|
**Calendar**
|
||||||
./gradlew lint
|
|
||||||
|
- 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
|
```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
|
[MIT](LICENSE) — Jean-Luc Makiola, 2026
|
||||||
|
|||||||
@@ -23,8 +23,13 @@ android {
|
|||||||
applicationId = "de.jeanlucmakiola.calendula"
|
applicationId = "de.jeanlucmakiola.calendula"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 13
|
// The git tag is the single source of truth for released builds: at
|
||||||
versionName = "2.0.0"
|
// 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"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.Today
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
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):
|
* Visual language (kept deliberately small so sizes don't drift):
|
||||||
* - Drawer title — `titleLarge`
|
* - Drawer title — `titleLarge`
|
||||||
* - Section headers (e.g. "Calendars") — `titleSmall`, primary, text only
|
* - 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)
|
* (`labelLarge` label + a single 24dp leading icon)
|
||||||
*
|
*
|
||||||
* Hosts the per-calendar visibility filter (M3) inline — the calendar list with
|
* The "View" section mirrors the top-bar switcher pill: tapping a view here
|
||||||
* its checkboxes lives here rather than in a separate sheet — plus the "today"
|
* selects it (and closes the drawer) rather than cycling. Also hosts the
|
||||||
* jump and a Settings entry (M4). The host screen owns the drawer state.
|
* 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
|
@Composable
|
||||||
fun CalendarDrawer(
|
fun CalendarDrawer(
|
||||||
onToday: () -> Unit,
|
currentView: CalendarView,
|
||||||
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
) {
|
) {
|
||||||
ModalDrawerSheet {
|
ModalDrawerSheet {
|
||||||
@@ -47,14 +49,17 @@ fun CalendarDrawer(
|
|||||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp),
|
modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp),
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
|
DrawerSectionHeader(stringResource(R.string.view_section))
|
||||||
|
IMPLEMENTED_VIEWS.forEach { view ->
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
icon = { Icon(Icons.Filled.Today, contentDescription = null) },
|
icon = { Icon(view.icon, contentDescription = null) },
|
||||||
label = { Text(stringResource(R.string.month_today_action)) },
|
label = { Text(stringResource(view.labelRes)) },
|
||||||
selected = false,
|
selected = view == currentView,
|
||||||
onClick = onToday,
|
onClick = { onSelectView(view) },
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
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).
|
* The top-level calendar views the user can switch between (spec M1).
|
||||||
* Day is declared but not yet implemented (v0.5) — see [IMPLEMENTED_VIEWS].
|
* Day is declared but not yet implemented (v0.5) — see [IMPLEMENTED_VIEWS].
|
||||||
@@ -10,6 +18,23 @@ enum class CalendarView {
|
|||||||
Day,
|
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
|
* Views that actually have a screen today. The view-switcher pill cycles
|
||||||
* through these in order.
|
* through these in order.
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
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
|
* Top-bar pill that shows the current view and cycles to the next one on tap
|
||||||
@@ -18,16 +17,11 @@ fun ViewSwitcherPill(
|
|||||||
onCycle: () -> Unit,
|
onCycle: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
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(
|
FilledTonalButton(
|
||||||
onClick = onCycle,
|
onClick = onCycle,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Text(stringResource(labelRes))
|
Text(stringResource(current.labelRes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,7 +157,11 @@ fun DayScreen(
|
|||||||
gesturesEnabled = drawerState.isOpen,
|
gesturesEnabled = drawerState.isOpen,
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
|
currentView = selectedView,
|
||||||
|
onSelectView = { view ->
|
||||||
|
onSelectView(view)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
onSettings = {
|
onSettings = {
|
||||||
onOpenSettings()
|
onOpenSettings()
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
prefs.lastUsedCalendarId,
|
prefs.lastUsedCalendarId,
|
||||||
settingsPrefs.defaultFormFields,
|
settingsPrefs.defaultFormFields,
|
||||||
::ExternalInputs,
|
::ExternalInputs,
|
||||||
),
|
).flowOn(io),
|
||||||
) { local, external ->
|
) { local, external ->
|
||||||
val form = local.form ?: return@combine null
|
val form = local.form ?: return@combine null
|
||||||
val resolvedId = form.calendarId
|
val resolvedId = form.calendarId
|
||||||
@@ -131,7 +131,6 @@ class EventEditViewModel @Inject constructor(
|
|||||||
resolved.rrule != local.editTarget.original.rrule,
|
resolved.rrule != local.editTarget.original.rrule,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.flowOn(io)
|
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.month
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
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.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Menu
|
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.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -43,7 +43,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
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.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
@@ -130,8 +131,9 @@ fun MonthScreen(
|
|||||||
gesturesEnabled = drawerState.isOpen,
|
gesturesEnabled = drawerState.isOpen,
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
onToday = {
|
currentView = selectedView,
|
||||||
jumpToToday()
|
onSelectView = { view ->
|
||||||
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
},
|
},
|
||||||
onSettings = {
|
onSettings = {
|
||||||
@@ -177,7 +179,6 @@ fun MonthScreen(
|
|||||||
WeekdayHeader(weekStart = weekStart)
|
WeekdayHeader(weekStart = weekStart)
|
||||||
MonthContent(
|
MonthContent(
|
||||||
state = state,
|
state = state,
|
||||||
weekStart = weekStart,
|
|
||||||
slideDir = slideDir,
|
slideDir = slideDir,
|
||||||
onSwipeNext = goNext,
|
onSwipeNext = goNext,
|
||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
@@ -192,7 +193,6 @@ fun MonthScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MonthContent(
|
private fun MonthContent(
|
||||||
state: MonthUiState,
|
state: MonthUiState,
|
||||||
weekStart: DayOfWeek,
|
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
onSwipeNext: () -> Unit,
|
onSwipeNext: () -> Unit,
|
||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
@@ -237,7 +237,6 @@ private fun MonthContent(
|
|||||||
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
is MonthUiState.Success -> MonthGrid(
|
is MonthUiState.Success -> MonthGrid(
|
||||||
state = s,
|
state = s,
|
||||||
weekStart = weekStart,
|
|
||||||
onOpenDay = onOpenDay,
|
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
|
@Composable
|
||||||
private fun MonthGrid(
|
private fun MonthGrid(
|
||||||
state: MonthUiState.Success,
|
state: MonthUiState.Success,
|
||||||
weekStart: DayOfWeek,
|
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
repeat(weeks) { row ->
|
state.weeks.forEach { week ->
|
||||||
Row(
|
MonthWeekRow(
|
||||||
|
week = week,
|
||||||
|
today = state.today,
|
||||||
|
month = state.month,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f),
|
.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
|
@Composable
|
||||||
private fun DayCard(
|
private fun MonthWeekRow(
|
||||||
date: LocalDate,
|
week: MonthWeek,
|
||||||
isToday: Boolean,
|
today: LocalDate,
|
||||||
data: DayCellData?,
|
month: YearMonth,
|
||||||
onClick: () -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val todayPrefix = stringResource(R.string.month_a11y_today_prefix)
|
val dark = isSystemInDarkTheme()
|
||||||
val cellLabel = buildString {
|
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||||
if (isToday) append(todayPrefix).append(", ")
|
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
||||||
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
|
BoxWithConstraints(modifier) {
|
||||||
// scheme drives a subtle scale, instead of a fixed easing curve.
|
val colW = maxWidth / 7
|
||||||
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(
|
// Per-day background pills — same surfaceContainer rounded surface the
|
||||||
onClick = onClick,
|
// week/day views use, so the three views share one visual language.
|
||||||
interactionSource = interactionSource,
|
// Spanning bars draw on top of these, bridging cells, so they still read
|
||||||
shape = MaterialTheme.shapes.medium,
|
// as one continuous event.
|
||||||
colors = CardDefaults.cardColors(
|
Row(Modifier.matchParentSize()) {
|
||||||
containerColor = if (isToday) MaterialTheme.colorScheme.primaryContainer
|
week.days.forEach { d ->
|
||||||
else MaterialTheme.colorScheme.surfaceContainerHigh,
|
val inMonth = d.month == month.month && d.year == month.year
|
||||||
contentColor = if (isToday) MaterialTheme.colorScheme.onPrimaryContainer
|
Box(
|
||||||
else MaterialTheme.colorScheme.onSurface,
|
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
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxWidth()
|
||||||
.padding(top = 4.dp, bottom = 2.dp),
|
.weight(1f)
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
.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(
|
||||||
text = date.day.toString(),
|
text = date.day.toString(),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
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
|
@Composable
|
||||||
private fun EventDotRow(data: DayCellData?) {
|
private fun MonthBar(
|
||||||
if (data == null || data.swatches.isEmpty()) {
|
event: de.jeanlucmakiola.calendula.domain.EventInstance,
|
||||||
Spacer(Modifier.height(6.dp))
|
dark: Boolean,
|
||||||
return
|
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(
|
Row(
|
||||||
|
modifier = modifier.height(EVENT_ROW_HEIGHT),
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
data.swatches.forEach { argb ->
|
colors.forEach { argb ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(6.dp)
|
.size(6.dp)
|
||||||
.background(pastelize(argb, dark), CircleShape),
|
.background(pastelize(argb, dark), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (data.count > data.swatches.size) {
|
if (extra > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = "+${data.count - data.swatches.size}",
|
text = "+$extra",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,20 +1,40 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.month
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-day aggregation surfaced to the month grid. We only need
|
* An all-day or multi-day event laid out as one connected horizontal bar across
|
||||||
* - the total event count (drives the optional "+N" indicator), and
|
* a week row, [startCol]..[endCol], stacked on [lane] so overlapping spans don't
|
||||||
* - up to three calendar colors for the dot row.
|
* 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
|
||||||
* The day cell never holds full event objects — the detail sheet pulls those
|
* later one) drops its rounded cap on that side.
|
||||||
* lazily.
|
|
||||||
*/
|
*/
|
||||||
data class DayCellData(
|
data class MonthSpan(
|
||||||
val count: Int,
|
val event: EventInstance,
|
||||||
val swatches: List<Int>,
|
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 {
|
sealed interface MonthUiState {
|
||||||
@@ -23,6 +43,6 @@ sealed interface MonthUiState {
|
|||||||
data class Success(
|
data class Success(
|
||||||
val month: YearMonth,
|
val month: YearMonth,
|
||||||
val today: LocalDate,
|
val today: LocalDate,
|
||||||
val cells: Map<LocalDate, DayCellData>,
|
val weeks: List<MonthWeek>,
|
||||||
) : MonthUiState
|
) : MonthUiState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
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.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -71,7 +73,7 @@ class MonthViewModel @Inject constructor(
|
|||||||
repository.calendars(),
|
repository.calendars(),
|
||||||
repository.instances(range),
|
repository.instances(range),
|
||||||
) { calendars, instances ->
|
) { calendars, instances ->
|
||||||
buildState(ym, calendars, instances)
|
buildState(ym, ws, calendars, instances)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
@@ -96,25 +98,64 @@ class MonthViewModel @Inject constructor(
|
|||||||
|
|
||||||
private fun buildState(
|
private fun buildState(
|
||||||
ym: YearMonth,
|
ym: YearMonth,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
): MonthUiState {
|
): MonthUiState {
|
||||||
if (calendars.isEmpty()) {
|
if (calendars.isEmpty()) {
|
||||||
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
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(
|
return MonthUiState.Success(
|
||||||
month = ym,
|
month = ym,
|
||||||
today = todayDate,
|
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,
|
gesturesEnabled = drawerState.isOpen,
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
|
currentView = selectedView,
|
||||||
|
onSelectView = { view ->
|
||||||
|
onSelectView(view)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
onSettings = {
|
onSettings = {
|
||||||
onOpenSettings()
|
onOpenSettings()
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
|
|||||||
@@ -187,6 +187,7 @@
|
|||||||
<string name="view_month">Monat</string>
|
<string name="view_month">Monat</string>
|
||||||
<string name="view_week">Woche</string>
|
<string name="view_week">Woche</string>
|
||||||
<string name="view_day">Tag</string>
|
<string name="view_day">Tag</string>
|
||||||
|
<string name="view_section">Ansicht</string>
|
||||||
|
|
||||||
<!-- Kalender-Filter (M3) -->
|
<!-- Kalender-Filter (M3) -->
|
||||||
<string name="filter_title">Kalender</string>
|
<string name="filter_title">Kalender</string>
|
||||||
|
|||||||
@@ -188,6 +188,7 @@
|
|||||||
<string name="view_month">Month</string>
|
<string name="view_month">Month</string>
|
||||||
<string name="view_week">Week</string>
|
<string name="view_week">Week</string>
|
||||||
<string name="view_day">Day</string>
|
<string name="view_day">Day</string>
|
||||||
|
<string name="view_section">View</string>
|
||||||
|
|
||||||
<!-- Calendar filter (M3) -->
|
<!-- Calendar filter (M3) -->
|
||||||
<string name="filter_title">Calendars</string>
|
<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