Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15fb76005c | |||
| c27a645c19 | |||
| 21e7b1ff91 | |||
| 31163da868 | |||
| 9a1903e6ed | |||
| f990af1cb0 | |||
| e5be5f1ae5 | |||
| 54aed73726 | |||
| 82c3e1d605 | |||
| e5b523e907 | |||
| d028b70e6e | |||
| 626623bb6e | |||
| 264b2a86c1 | |||
| b03bd67678 |
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ after v0.6 (full event read) plus the onboarding-screen polish pass.
|
|||||||
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
|
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
|
||||||
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
|
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
|
||||||
|
|
||||||
## v2.0 — Write Support (in progress)
|
## v2.0 — Write Support (complete, shipped 2026-06-11)
|
||||||
|
|
||||||
Delivered in four releasable slices (plan:
|
Delivered in four releasable slices (plan:
|
||||||
`docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a
|
`docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a
|
||||||
@@ -65,8 +65,22 @@ guide here, not a contract — scope per slice is decided as we go.
|
|||||||
| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
|
| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
|
||||||
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
|
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
|
||||||
| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) |
|
| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) |
|
||||||
| v1.4 | Reminder notifications — see below | planned |
|
| v1.4 | Reminder notifications — see below | complete (shipped 2026-06-11) |
|
||||||
| v2.0 | Quick-add, occurrence edit, conflict dialog, polish, release | planned |
|
| v2.0 | Conflict dialog, polish pass (store copy refresh, F-Droid screenshots), release | complete (shipped 2026-06-11) |
|
||||||
|
|
||||||
|
v2.0 scope was re-cut on 2026-06-11, after v1.4:
|
||||||
|
- **Occurrence edit** already shipped early, in v1.3.
|
||||||
|
- **Quick-add** is **cut from scope**: the full form already opens prefilled
|
||||||
|
(visible day, last-used calendar, optional fields hidden), so the sheet
|
||||||
|
would only save one screen transition while adding a second create-surface
|
||||||
|
to maintain. Revisit only if real-world feedback says creation feels heavy.
|
||||||
|
- **Calendar switching while editing** moves to the v3 backlog (sync-adapter
|
||||||
|
minefield: `CALENDAR_ID` is sync-adapter-owned, AOSP locks the field; an
|
||||||
|
honest implementation is copy+delete like Google Calendar, with sync-identity
|
||||||
|
and attendee side effects).
|
||||||
|
- **Conflict dialog** stays (plan 03, decision 5): on save, compare against
|
||||||
|
the row as it was when the form loaded; on external change, ask
|
||||||
|
overwrite / discard. Closes the silent-clobber gap on synced calendars.
|
||||||
|
|
||||||
## v1.4 — Reminder Notifications
|
## v1.4 — Reminder Notifications
|
||||||
|
|
||||||
@@ -99,5 +113,53 @@ Deliberately deferred (add only if needed):
|
|||||||
- Full-text search
|
- Full-text search
|
||||||
- Tablet / foldable layouts
|
- Tablet / foldable layouts
|
||||||
- Optional: ICS file import (drag-and-drop)
|
- Optional: ICS file import (drag-and-drop)
|
||||||
|
- Optional: move event to another calendar (copy+delete model with a
|
||||||
|
consequences warning — deferred from v2.0, see above)
|
||||||
|
|
||||||
Order is indicative — community feedback after V1 may re-prioritize.
|
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
|
||||||
|
|||||||
60
CHANGELOG.md
@@ -7,6 +7,66 @@ 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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Conflict handling when saving an edit: if the event changed elsewhere
|
||||||
|
(sync, another device) while the form was open, saving now asks whether
|
||||||
|
to keep or discard your changes instead of silently overwriting the
|
||||||
|
edited fields — and tells you when the event was deleted in the meantime.
|
||||||
|
"Keep" still writes only the fields you touched; external changes to
|
||||||
|
untouched fields survive either way
|
||||||
|
- F-Droid store screenshots (German + English), captured with demo data
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- F-Droid description and README no longer claim the app is read-only —
|
||||||
|
they now describe write support and reminder delivery
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `versionName`/`versionCode` bumped to 2.0.0 / 13 — closing out the
|
||||||
|
write-support milestone (v1.1 through v2.0)
|
||||||
|
|
||||||
|
## [1.4.0] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Reminder notifications (v1.4): Calendula now delivers event reminders as
|
||||||
|
notifications itself — the system schedules them but posts nothing, so a
|
||||||
|
calendar app must (essential when Calendula is the only one installed).
|
||||||
|
Due reminders appear on a dedicated "Event reminders" channel; tapping one
|
||||||
|
opens the event's detail screen. Email reminders are never posted (the
|
||||||
|
provider only schedules alert-type reminders)
|
||||||
|
- A one-time onboarding step after the calendar grant introduces reminders,
|
||||||
|
requests the notification permission (Android 13+), and warns that a second
|
||||||
|
calendar app with notifications on will duplicate them. "Not now" leaves
|
||||||
|
the feature off
|
||||||
|
- Settings gained a "Notifications" section mirroring the choice: an event-
|
||||||
|
reminders toggle (default on) with the duplicate-reminders hint; turning it
|
||||||
|
on re-requests the notification permission when missing
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `versionName`/`versionCode` bumped to 1.4.0 / 12
|
||||||
|
|
||||||
## [1.3.0] — 2026-06-11
|
## [1.3.0] — 2026-06-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
129
README.md
@@ -1,43 +1,120 @@
|
|||||||
# Calendula
|
<div align="center">
|
||||||
|
|
||||||
A modern Material 3 Expressive calendar app for Android.
|
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/icon.png" width="112" alt="Calendula icon">
|
||||||
|
|
||||||
Calendula is named after the flower of the same name, whose name comes from
|
<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 (V1)
|
<p><strong>A modern Material 3 Expressive calendar for Android.</strong><br>
|
||||||
|
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
|
||||||
|
|
||||||
- Month, Week, and Day views
|
<p>
|
||||||
- Read-only event details (write support comes in V2)
|
<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>
|
||||||
- Multi-calendar visibility toggle
|
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
|
||||||
- Material You Dynamic Color (Android 12+)
|
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
|
||||||
- Light/Dark theme follows system
|
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
|
||||||
- German + English UI
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
## Building
|
<p>
|
||||||
|
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/01-week.png" width="19%" alt="Week view">
|
||||||
|
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/02-month.png" width="19%" alt="Month view">
|
||||||
|
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">
|
||||||
|
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/05-edit.png" width="19%" alt="Event form">
|
||||||
|
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding">
|
||||||
|
</p>
|
||||||
|
|
||||||
Requires Android SDK 36 and JDK 17. The Gradle wrapper is checked in, so no host Gradle install is needed:
|
</div>
|
||||||
|
|
||||||
```bash
|
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 = 11
|
// The git tag is the single source of truth for released builds: at
|
||||||
versionName = "1.3.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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".CalendulaApp"
|
android:name=".CalendulaApp"
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
@@ -26,6 +28,20 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
||||||
|
no notification itself — a calendar app must (v1.4, Etar model).
|
||||||
|
Exported: the broadcast arrives from the provider's process. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".data.reminders.EventReminderReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.EVENT_REMINDER" />
|
||||||
|
<data
|
||||||
|
android:host="com.android.calendar"
|
||||||
|
android:scheme="content" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
||||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula
|
package de.jeanlucmakiola.calendula
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
@@ -7,7 +9,10 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
@@ -18,9 +23,16 @@ import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
|||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
// The occurrence a reminder notification was tapped for (eventId, begin,
|
||||||
|
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
||||||
|
// tap into the running activity; CalendarHost consumes and clears it.
|
||||||
|
private var requestedDetailKey by mutableStateOf<LongArray?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
requestedDetailKey = intent.detailKeyOrNull()
|
||||||
setContent {
|
setContent {
|
||||||
// One activity-scoped SettingsViewModel drives both the theme here
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
// and the Settings screen, so a theme change applies app-wide at once.
|
// and the Settings screen, so a theme change applies app-wide at once.
|
||||||
@@ -35,8 +47,51 @@ class MainActivity : ComponentActivity() {
|
|||||||
darkTheme = darkTheme,
|
darkTheme = darkTheme,
|
||||||
dynamicColor = settings.dynamicColor,
|
dynamicColor = settings.dynamicColor,
|
||||||
) {
|
) {
|
||||||
RootScreen(modifier = Modifier.fillMaxSize())
|
RootScreen(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
requestedDetailKey = requestedDetailKey,
|
||||||
|
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Intent.detailKeyOrNull(): LongArray? {
|
||||||
|
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||||
|
if (eventId == -1L) return null
|
||||||
|
return longArrayOf(
|
||||||
|
eventId,
|
||||||
|
getLongExtra(EXTRA_BEGIN_MILLIS, 0L),
|
||||||
|
getLongExtra(EXTRA_END_MILLIS, 0L),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
||||||
|
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
||||||
|
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intent opening the detail screen of one occurrence (reminder
|
||||||
|
* notifications). The synthetic data URI keys the intent so
|
||||||
|
* PendingIntents for different occurrences never collapse into one.
|
||||||
|
*/
|
||||||
|
fun eventDetailIntent(
|
||||||
|
context: Context,
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
endMillis: Long,
|
||||||
|
): Intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
data = "calendula://event/$eventId/$beginMillis".toUri()
|
||||||
|
putExtra(EXTRA_EVENT_ID, eventId)
|
||||||
|
putExtra(EXTRA_BEGIN_MILLIS, beginMillis)
|
||||||
|
putExtra(EXTRA_END_MILLIS, endMillis)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -37,6 +39,12 @@ abstract class DataBindModule {
|
|||||||
abstract fun bindCalendarRepository(
|
abstract fun bindCalendarRepository(
|
||||||
impl: CalendarRepositoryImpl,
|
impl: CalendarRepositoryImpl,
|
||||||
): CalendarRepository
|
): CalendarRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindReminderAlertStore(
|
||||||
|
impl: AndroidReminderAlertStore,
|
||||||
|
): ReminderAlertStore
|
||||||
}
|
}
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|||||||
@@ -86,6 +86,31 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
||||||
|
* for users whose only calendar app this is, reminders are essential; the
|
||||||
|
* onboarding step and Settings warn about duplicates from a second app.
|
||||||
|
*/
|
||||||
|
val remindersEnabled: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[REMINDERS_ENABLED_KEY] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setRemindersEnabled(enabled: Boolean) {
|
||||||
|
store.edit { it[REMINDERS_ENABLED_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the one-time reminder onboarding step (after the calendar
|
||||||
|
* grant) has been shown — also true for users who tapped "not now".
|
||||||
|
*/
|
||||||
|
val reminderOnboardingDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[REMINDER_ONBOARDING_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setReminderOnboardingDone() {
|
||||||
|
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||||
null -> DEFAULT_FORM_FIELDS
|
null -> DEFAULT_FORM_FIELDS
|
||||||
else -> stored.split(',')
|
else -> stored.split(',')
|
||||||
@@ -98,6 +123,8 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||||
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
internal val DEFAULT_FORM_FIELDS =
|
internal val DEFAULT_FORM_FIELDS =
|
||||||
setOf(EventFormField.Location, EventFormField.Description)
|
setOf(EventFormField.Location, EventFormField.Description)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Becomes the app that turns the calendar provider's reminder alarms into
|
||||||
|
* visible notifications (the Etar model — the provider broadcasts
|
||||||
|
* `EVENT_REMINDER` at reminder time but posts nothing itself).
|
||||||
|
*
|
||||||
|
* The broadcast's data URI only carries the alarm time, so it is ignored:
|
||||||
|
* we query every still-scheduled, due `CalendarAlerts` row ourselves, post
|
||||||
|
* them, and mark them fired. Posting happens before marking — a crash in
|
||||||
|
* between re-posts silently (same tag) rather than losing the reminder.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class EventReminderReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@Inject lateinit var alertStore: ReminderAlertStore
|
||||||
|
@Inject lateinit var notifier: ReminderNotifier
|
||||||
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||||
|
val readGranted = ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.READ_CALENDAR,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!readGranted || !notifier.canPost()) return
|
||||||
|
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
if (settingsPrefs.remindersEnabled.first()) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val due = alertStore.dueAlerts(now)
|
||||||
|
due.forEach(notifier::post)
|
||||||
|
alertStore.markFired(due.map { it.alertId }, now)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import android.util.Log
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One due row of the provider's `CalendarAlerts` table (a join with Events).
|
||||||
|
* Stays in the data layer: alerts feed the notification path only and never
|
||||||
|
* reach a screen, so there is no domain model for them.
|
||||||
|
*/
|
||||||
|
data class ReminderAlert(
|
||||||
|
val alertId: Long,
|
||||||
|
val eventId: Long,
|
||||||
|
val beginMillis: Long,
|
||||||
|
val endMillis: Long,
|
||||||
|
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||||
|
val title: String,
|
||||||
|
val location: String?,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seam over the `CalendarAlerts` table so the receiver logic can be exercised
|
||||||
|
* without a ContentResolver. The provider creates these rows itself — only
|
||||||
|
* for `METHOD_ALERT` reminders (verified in AOSP `CalendarAlarmManager`), so
|
||||||
|
* email reminders never show up here.
|
||||||
|
*/
|
||||||
|
interface ReminderAlertStore {
|
||||||
|
|
||||||
|
/** Alerts that are due (`ALARM_TIME` has passed) and still unhandled. */
|
||||||
|
fun dueAlerts(nowMillis: Long): List<ReminderAlert>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the given alerts handled (`STATE_FIRED`) so a later broadcast does
|
||||||
|
* not surface them again. Best effort: this write needs `WRITE_CALENDAR`,
|
||||||
|
* which the user may have declined — then re-broadcasts silently replace
|
||||||
|
* the already-posted notifications instead (same tag, alert-once).
|
||||||
|
*/
|
||||||
|
fun markFired(alertIds: List<Long>, nowMillis: Long)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AndroidReminderAlertStore @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : ReminderAlertStore {
|
||||||
|
|
||||||
|
override fun dueAlerts(nowMillis: Long): List<ReminderAlert> = context.contentResolver.query(
|
||||||
|
CalendarContract.CalendarAlerts.CONTENT_URI,
|
||||||
|
PROJECTION,
|
||||||
|
CalendarContract.CalendarAlerts.STATE + " = ? AND " +
|
||||||
|
CalendarContract.CalendarAlerts.ALARM_TIME + " <= ?",
|
||||||
|
arrayOf(
|
||||||
|
CalendarContract.CalendarAlerts.STATE_SCHEDULED.toString(),
|
||||||
|
nowMillis.toString(),
|
||||||
|
),
|
||||||
|
CalendarContract.CalendarAlerts.BEGIN + " ASC",
|
||||||
|
)?.use { c ->
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
add(
|
||||||
|
ReminderAlert(
|
||||||
|
alertId = c.getLong(0),
|
||||||
|
eventId = c.getLong(1),
|
||||||
|
beginMillis = c.getLong(2),
|
||||||
|
endMillis = c.getLong(3),
|
||||||
|
title = c.getString(4).orEmpty(),
|
||||||
|
location = c.getString(5)?.takeIf { it.isNotBlank() },
|
||||||
|
isAllDay = c.getInt(6) == 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
override fun markFired(alertIds: List<Long>, nowMillis: Long) {
|
||||||
|
if (alertIds.isEmpty()) return
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.CalendarAlerts.STATE, CalendarContract.CalendarAlerts.STATE_FIRED)
|
||||||
|
put(CalendarContract.CalendarAlerts.RECEIVED_TIME, nowMillis)
|
||||||
|
put(CalendarContract.CalendarAlerts.NOTIFY_TIME, nowMillis)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
context.contentResolver.update(
|
||||||
|
CalendarContract.CalendarAlerts.CONTENT_URI,
|
||||||
|
values,
|
||||||
|
CalendarContract.CalendarAlerts._ID +
|
||||||
|
" IN (" + alertIds.joinToString(",") + ")",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "Cannot mark alerts fired without WRITE_CALENDAR", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ReminderAlertStore"
|
||||||
|
val PROJECTION = arrayOf(
|
||||||
|
CalendarContract.CalendarAlerts._ID,
|
||||||
|
CalendarContract.CalendarAlerts.EVENT_ID,
|
||||||
|
CalendarContract.CalendarAlerts.BEGIN,
|
||||||
|
CalendarContract.CalendarAlerts.END,
|
||||||
|
CalendarContract.CalendarAlerts.TITLE,
|
||||||
|
CalendarContract.CalendarAlerts.EVENT_LOCATION,
|
||||||
|
CalendarContract.CalendarAlerts.ALL_DAY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.Locale
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts one notification per due reminder alert on a dedicated channel.
|
||||||
|
* Tapping opens the event's detail screen; the tag is the alert id, so a
|
||||||
|
* re-broadcast of an alert we couldn't mark fired replaces its notification
|
||||||
|
* silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of
|
||||||
|
* duplicating it.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReminderNotifier @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||||
|
fun canPost(): Boolean {
|
||||||
|
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun post(alert: ReminderAlert) {
|
||||||
|
ensureChannel()
|
||||||
|
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
|
val time = reminderTimeText(
|
||||||
|
beginMillis = alert.beginMillis,
|
||||||
|
endMillis = alert.endMillis,
|
||||||
|
isAllDay = alert.isAllDay,
|
||||||
|
zone = ZoneId.systemDefault(),
|
||||||
|
locale = Locale.getDefault(),
|
||||||
|
)
|
||||||
|
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setWhen(alert.beginMillis)
|
||||||
|
.setShowWhen(false)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_EVENT)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setContentIntent(detailIntent(alert))
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
NotificationManagerCompat.from(context)
|
||||||
|
.notify(alert.alertId.toString(), NOTIFICATION_ID, notification)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
||||||
|
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
/* requestCode = */ alert.alertId.toInt(),
|
||||||
|
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Channel creation is idempotent; re-running refreshes the localized name. */
|
||||||
|
private fun ensureChannel() {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
context.getString(R.string.reminder_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
|
).apply {
|
||||||
|
description = context.getString(R.string.reminder_channel_description)
|
||||||
|
}
|
||||||
|
NotificationManagerCompat.from(context).createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ReminderNotifier"
|
||||||
|
const val CHANNEL_ID = "reminders"
|
||||||
|
|
||||||
|
// One id, distinct tags: the tag (alert id) already keys the
|
||||||
|
// notification, so a fixed id keeps cancellation/replacement simple.
|
||||||
|
const val NOTIFICATION_ID = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one line of time context in a reminder notification. Pure so it can be
|
||||||
|
* JVM-tested:
|
||||||
|
*
|
||||||
|
* - timed, same day: "09:30 – 10:00"
|
||||||
|
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time)
|
||||||
|
* - all-day, one day: "11 Jun 2026"
|
||||||
|
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
||||||
|
*
|
||||||
|
* All-day instances store UTC midnights with an exclusive end, so they are
|
||||||
|
* read in UTC and the end day is the last *covered* day.
|
||||||
|
*/
|
||||||
|
fun reminderTimeText(
|
||||||
|
beginMillis: Long,
|
||||||
|
endMillis: Long,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
zone: ZoneId,
|
||||||
|
locale: Locale,
|
||||||
|
): String {
|
||||||
|
if (isAllDay) {
|
||||||
|
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
|
// (atZone().toLocalDate() instead of LocalDate.ofInstant — API 34+)
|
||||||
|
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
.minusDays(1)
|
||||||
|
.coerceAtLeast(firstDay)
|
||||||
|
return if (lastDay == firstDay) {
|
||||||
|
dateFormat.format(firstDay)
|
||||||
|
} else {
|
||||||
|
dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||||
|
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
||||||
|
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
||||||
|
return if (begin.toLocalDate() == end.toLocalDate()) {
|
||||||
|
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||||
|
} else {
|
||||||
|
val dateTimeFormat = DateTimeFormatter
|
||||||
|
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
|
||||||
|
.withLocale(locale)
|
||||||
|
dateTimeFormat.format(begin) + RANGE + dateTimeFormat.format(end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val RANGE = " – "
|
||||||
@@ -94,6 +94,30 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the edit form saw when it loaded — compared against a fresh read at
|
||||||
|
* save time to detect external changes (sync, another device) that landed
|
||||||
|
* while the form was open. The raw row times ride along because
|
||||||
|
* [toEditForm] derives the form's times from the *tapped occurrence*, so
|
||||||
|
* re-deriving with the same occurrence would mask an externally moved
|
||||||
|
* event. Not covered (the form can't write them, and the dirty-checked
|
||||||
|
* write can't clobber them): attendees, status, the user's own response,
|
||||||
|
* reminder methods, and a recurring event's duration.
|
||||||
|
*/
|
||||||
|
data class EditSnapshot(
|
||||||
|
val form: EventForm,
|
||||||
|
/** The raw Events-row times (for recurring events: the series anchor). */
|
||||||
|
val rowStart: Instant,
|
||||||
|
val rowEnd: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZone): EditSnapshot =
|
||||||
|
EditSnapshot(
|
||||||
|
form = toEditForm(beginMillis, endMillis, zone),
|
||||||
|
rowStart = instance.start,
|
||||||
|
rowEnd = instance.end,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The optional sections that hold a value in [form] — when editing, these
|
* The optional sections that hold a value in [form] — when editing, these
|
||||||
* must be visible regardless of the user's default-fields setting, or the
|
* must be visible regardless of the user's default-fields setting, or the
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.compose.animation.slideOutHorizontally
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -29,9 +30,18 @@ import kotlinx.datetime.LocalDate
|
|||||||
* Holds the active top-level view (spec M1) and swaps between the calendar
|
* Holds the active top-level view (spec M1) and swaps between the calendar
|
||||||
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
||||||
* pill in their top bars writes back here via [onSelectView].
|
* pill in their top bars writes back here via [onSelectView].
|
||||||
|
*
|
||||||
|
* [requestedDetailKey] is an externally requested occurrence (a tapped
|
||||||
|
* reminder notification routed through MainActivity): it opens the detail
|
||||||
|
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed]
|
||||||
|
* so a later recomposition can't re-open it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarHost(modifier: Modifier = Modifier) {
|
fun CalendarHost(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
requestedDetailKey: LongArray? = null,
|
||||||
|
onDetailKeyConsumed: () -> Unit = {},
|
||||||
|
) {
|
||||||
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
||||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
val onSelectView: (CalendarView) -> Unit = { view = it }
|
||||||
|
|
||||||
@@ -61,6 +71,15 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
detailKey = key
|
detailKey = key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A tapped reminder notification asks for a specific occurrence.
|
||||||
|
LaunchedEffect(requestedDetailKey) {
|
||||||
|
if (requestedDetailKey != null) {
|
||||||
|
heldKey = requestedDetailKey
|
||||||
|
detailKey = requestedDetailKey
|
||||||
|
onDetailKeyConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Settings (M4) is hoisted here so it overlays whichever calendar view is
|
// Settings (M4) is hoisted here so it overlays whichever calendar view is
|
||||||
// active and survives view switches. (The calendar filter now lives inline
|
// active and survives view switches. (The calendar filter now lives inline
|
||||||
// in the navigation drawer, so no overlay state is needed for it.)
|
// in the navigation drawer, so no overlay state is needed for it.)
|
||||||
|
|||||||
@@ -10,14 +10,22 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RootScreen(modifier: Modifier = Modifier) {
|
fun RootScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
requestedDetailKey: LongArray? = null,
|
||||||
|
onDetailKeyConsumed: () -> Unit = {},
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -40,7 +48,23 @@ fun RootScreen(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasPermission) {
|
if (hasPermission) {
|
||||||
CalendarHost(modifier = modifier)
|
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
||||||
|
// Null until DataStore's first emission — render nothing for that
|
||||||
|
// frame instead of flashing the wrong screen.
|
||||||
|
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||||
|
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||||
|
when (onboardingDone) {
|
||||||
|
true -> CalendarHost(
|
||||||
|
modifier = modifier,
|
||||||
|
requestedDetailKey = requestedDetailKey,
|
||||||
|
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||||
|
)
|
||||||
|
false -> ReminderOnboardingScreen(
|
||||||
|
onFinished = reminderOnboarding::finish,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
PermissionScreen(
|
PermissionScreen(
|
||||||
onGranted = { hasPermission = true },
|
onGranted = { hasPermission = true },
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|||||||
@@ -217,7 +217,8 @@ fun EventEditScreen(
|
|||||||
viewModel.consumeSaveResult()
|
viewModel.consumeSaveResult()
|
||||||
snackbarHostState.showSnackbar(writeDeniedMessage)
|
snackbarHostState.showSnackbar(writeDeniedMessage)
|
||||||
}
|
}
|
||||||
SaveUiState.Idle, SaveUiState.AwaitingScope, SaveUiState.Saving, null -> Unit
|
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
|
||||||
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +270,68 @@ fun EventEditScreen(
|
|||||||
onDismiss = viewModel::consumeSaveResult,
|
onDismiss = viewModel::consumeSaveResult,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The event changed externally (sync) while the form was open (v2.0).
|
||||||
|
if (state?.saveState is SaveUiState.AwaitingConflict) {
|
||||||
|
SaveConflictDialog(
|
||||||
|
onOverwrite = viewModel::saveOverwriting,
|
||||||
|
onDiscard = close,
|
||||||
|
onDismiss = viewModel::consumeSaveResult,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...or was deleted underneath us — nothing left to save onto. Closing
|
||||||
|
// through [onSaved] also pops the detail screen, whose occurrence is gone.
|
||||||
|
if (state?.saveState == SaveUiState.Gone) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text(stringResource(R.string.event_edit_gone_title)) },
|
||||||
|
text = { Text(stringResource(R.string.event_edit_gone_body)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
viewModel.reset()
|
||||||
|
onSaved()
|
||||||
|
}) { Text(stringResource(R.string.dialog_ok)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overwrite-or-discard choice when the event changed underneath an open
|
||||||
|
* form (no locking; detected by re-reading at save time). "Overwrite" still
|
||||||
|
* only writes the fields the user edited — external changes to untouched
|
||||||
|
* fields survive either way. Cancelling returns to the form.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SaveConflictDialog(
|
||||||
|
onOverwrite: () -> Unit,
|
||||||
|
onDiscard: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.event_edit_conflict_title)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(stringResource(R.string.event_edit_conflict_body))
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_edit_conflict_overwrite),
|
||||||
|
supportingText = stringResource(R.string.event_edit_conflict_overwrite_hint),
|
||||||
|
onClick = onOverwrite,
|
||||||
|
)
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_edit_conflict_discard),
|
||||||
|
supportingText = stringResource(R.string.event_edit_conflict_discard_hint),
|
||||||
|
onClick = onDiscard,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UI state for the event form (v1.2: create; v1.3 reuses it for edit). Null
|
* UI state for the event form (v1.2: create; v1.3 reuses it for edit). Null
|
||||||
@@ -39,6 +40,14 @@ sealed interface SaveUiState {
|
|||||||
data object Idle : SaveUiState
|
data object Idle : SaveUiState
|
||||||
/** A dirty recurring event waits for the user to pick the write scope. */
|
/** A dirty recurring event waits for the user to pick the write scope. */
|
||||||
data object AwaitingScope : SaveUiState
|
data object AwaitingScope : SaveUiState
|
||||||
|
/**
|
||||||
|
* The event changed externally (sync) while the form was open; the save
|
||||||
|
* is parked with its chosen [scope] until the user picks overwrite,
|
||||||
|
* discard, or cancel.
|
||||||
|
*/
|
||||||
|
data class AwaitingConflict(val scope: RecurringWriteScope) : SaveUiState
|
||||||
|
/** The event was deleted externally while the form was open. */
|
||||||
|
data object Gone : SaveUiState
|
||||||
data object Saving : SaveUiState
|
data object Saving : SaveUiState
|
||||||
data object Saved : SaveUiState
|
data object Saved : SaveUiState
|
||||||
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
|
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
|
||||||
|
|||||||
@@ -4,18 +4,20 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||||
import de.jeanlucmakiola.calendula.domain.problems
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -68,15 +70,21 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event being edited plus the form exactly as it was prefilled.
|
* The event being edited plus everything the form saw at load time.
|
||||||
* For recurring events the write scope is chosen at save time; the
|
* For recurring events the write scope is chosen at save time; the
|
||||||
* tapped occurrence's [beginMillis] anchors occurrence-level writes.
|
* tapped occurrence's [beginMillis]/[endMillis] anchor occurrence-level
|
||||||
|
* writes and the conflict re-read. [zone] is pinned at load so a device
|
||||||
|
* timezone change mid-edit can't fake a conflict.
|
||||||
*/
|
*/
|
||||||
private data class EditTarget(
|
private data class EditTarget(
|
||||||
val eventId: Long,
|
val eventId: Long,
|
||||||
val original: EventForm,
|
val snapshot: EditSnapshot,
|
||||||
val beginMillis: Long,
|
val beginMillis: Long,
|
||||||
)
|
val endMillis: Long,
|
||||||
|
val zone: TimeZone,
|
||||||
|
) {
|
||||||
|
val original: EventForm get() = snapshot.form
|
||||||
|
}
|
||||||
|
|
||||||
private data class LocalInputs(
|
private data class LocalInputs(
|
||||||
val form: EventForm?,
|
val form: EventForm?,
|
||||||
@@ -101,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
|
||||||
@@ -123,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),
|
||||||
@@ -167,11 +174,12 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_loadFailed.value = true
|
_loadFailed.value = true
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val original = detail.toEditForm(beginMillis, endMillis, TimeZone.currentSystemDefault())
|
val zone = TimeZone.currentSystemDefault()
|
||||||
_editTarget.value = EditTarget(eventId, original, beginMillis)
|
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone)
|
||||||
|
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone)
|
||||||
// Sections holding data must show even when not in the defaults.
|
// Sections holding data must show even when not in the defaults.
|
||||||
_revealed.value = original.populatedFields()
|
_revealed.value = snapshot.form.populatedFields()
|
||||||
_form.value = original
|
_form.value = snapshot.form
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,10 +257,43 @@ class EventEditViewModel @Inject constructor(
|
|||||||
performSave(current.form, scope)
|
performSave(current.form, scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun performSave(form: EventForm, scope: RecurringWriteScope) {
|
/** Finish a save parked in [SaveUiState.AwaitingConflict], overwriting. */
|
||||||
|
fun saveOverwriting() {
|
||||||
|
val current = state.value ?: return
|
||||||
|
val parked = current.saveState as? SaveUiState.AwaitingConflict ?: return
|
||||||
|
performSave(current.form, parked.scope, ignoreConflict = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performSave(
|
||||||
|
form: EventForm,
|
||||||
|
scope: RecurringWriteScope,
|
||||||
|
ignoreConflict: Boolean = false,
|
||||||
|
) {
|
||||||
val target = _editTarget.value
|
val target = _editTarget.value
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_saveState.value = SaveUiState.Saving
|
_saveState.value = SaveUiState.Saving
|
||||||
|
// No locking (plan 03, decision 5): right before writing, re-read
|
||||||
|
// the event and compare against what the form loaded. An external
|
||||||
|
// change parks the save in a conflict dialog instead of silently
|
||||||
|
// clobbering the edited fields.
|
||||||
|
if (target != null && !ignoreConflict) {
|
||||||
|
val fresh = try {
|
||||||
|
repository.eventDetail(target.eventId)
|
||||||
|
.toEditSnapshot(target.beginMillis, target.endMillis, target.zone)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: NoSuchEventException) {
|
||||||
|
_saveState.value = SaveUiState.Gone
|
||||||
|
return@launch
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Can't verify — proceed; a real problem fails the write itself.
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (fresh != null && fresh != target.snapshot) {
|
||||||
|
_saveState.value = SaveUiState.AwaitingConflict(scope)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
_saveState.value = try {
|
_saveState.value = try {
|
||||||
if (target == null) {
|
if (target == null) {
|
||||||
repository.createEvent(form)
|
repository.createEvent(form)
|
||||||
|
|||||||
@@ -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) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.colorResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/** MD3 8dp spacing scale shared by the onboarding screens. */
|
||||||
|
internal object OnboardingSpace {
|
||||||
|
val xs = 8.dp
|
||||||
|
val sm = 16.dp
|
||||||
|
val md = 24.dp
|
||||||
|
val lg = 32.dp
|
||||||
|
val xl = 48.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared onboarding shell (calendar grant, reminder step): a scrollable,
|
||||||
|
* centred hero + body with the call(s) to action pinned to the bottom (clear
|
||||||
|
* of the navigation bar). The content slot is centred horizontally; benefit
|
||||||
|
* rows fill the width so their own content left-aligns.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun OnboardingScaffold(
|
||||||
|
hero: @Composable () -> Unit,
|
||||||
|
actions: @Composable ColumnScope.() -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
body: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
bottomBar = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
content = actions,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = OnboardingSpace.md),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||||
|
hero()
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||||
|
body()
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.md))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
|
||||||
|
@Composable
|
||||||
|
internal fun BrandHero(denied: Boolean) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(34.dp))
|
||||||
|
.background(colorResource(R.color.ic_launcher_background)),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = stringResource(R.string.app_name),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (denied) {
|
||||||
|
// A small lock badge sits over the corner to signal "blocked".
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.offset(x = 10.dp, y = 10.dp)
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.errorContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
|
||||||
|
@Composable
|
||||||
|
internal fun BenefitRow(icon: ImageVector, title: String, body: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.secondaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(OnboardingSpace.sm))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
text = body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,25 +6,14 @@ import android.net.Uri
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
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.navigationBarsPadding
|
|
||||||
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.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
@@ -34,7 +23,6 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -42,18 +30,13 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.colorResource
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
private val CALENDAR_PERMISSIONS = arrayOf(
|
private val CALENDAR_PERMISSIONS = arrayOf(
|
||||||
@@ -61,15 +44,6 @@ private val CALENDAR_PERMISSIONS = arrayOf(
|
|||||||
Manifest.permission.WRITE_CALENDAR,
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
)
|
)
|
||||||
|
|
||||||
// MD3 8dp spacing scale, scoped to this screen.
|
|
||||||
private object Space {
|
|
||||||
val xs = 8.dp
|
|
||||||
val sm = 16.dp
|
|
||||||
val md = 24.dp
|
|
||||||
val lg = 32.dp
|
|
||||||
val xl = 48.dp
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PermissionScreen(
|
fun PermissionScreen(
|
||||||
onGranted: () -> Unit,
|
onGranted: () -> Unit,
|
||||||
@@ -118,7 +92,7 @@ private fun RationaleContent(
|
|||||||
onRequest: () -> Unit,
|
onRequest: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
PermissionScaffold(
|
OnboardingScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
hero = { BrandHero(denied = false) },
|
hero = { BrandHero(denied = false) },
|
||||||
actions = {
|
actions = {
|
||||||
@@ -131,7 +105,7 @@ private fun RationaleContent(
|
|||||||
text = stringResource(R.string.permission_request_button),
|
text = stringResource(R.string.permission_request_button),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(Space.xs))
|
Spacer(Modifier.width(OnboardingSpace.xs))
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -147,7 +121,7 @@ private fun RationaleContent(
|
|||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
letterSpacing = 2.sp,
|
letterSpacing = 2.sp,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.xs))
|
Spacer(Modifier.height(OnboardingSpace.xs))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.permission_rationale_title),
|
text = stringResource(R.string.permission_rationale_title),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
@@ -161,20 +135,20 @@ private fun RationaleContent(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(Space.xl))
|
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||||
|
|
||||||
BenefitRow(
|
BenefitRow(
|
||||||
icon = Icons.Filled.Lock,
|
icon = Icons.Filled.Lock,
|
||||||
title = stringResource(R.string.permission_benefit_private_title),
|
title = stringResource(R.string.permission_benefit_private_title),
|
||||||
body = stringResource(R.string.permission_benefit_private_body),
|
body = stringResource(R.string.permission_benefit_private_body),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
BenefitRow(
|
BenefitRow(
|
||||||
icon = Icons.Filled.CalendarMonth,
|
icon = Icons.Filled.CalendarMonth,
|
||||||
title = stringResource(R.string.permission_benefit_sync_title),
|
title = stringResource(R.string.permission_benefit_sync_title),
|
||||||
body = stringResource(R.string.permission_benefit_sync_body),
|
body = stringResource(R.string.permission_benefit_sync_body),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
BenefitRow(
|
BenefitRow(
|
||||||
icon = Icons.Filled.VisibilityOff,
|
icon = Icons.Filled.VisibilityOff,
|
||||||
title = stringResource(R.string.permission_benefit_privacy_title),
|
title = stringResource(R.string.permission_benefit_privacy_title),
|
||||||
@@ -189,7 +163,7 @@ private fun DeniedContent(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
PermissionScaffold(
|
OnboardingScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
hero = { BrandHero(denied = true) },
|
hero = { BrandHero(denied = true) },
|
||||||
actions = {
|
actions = {
|
||||||
@@ -231,122 +205,6 @@ private fun DeniedContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared onboarding shell: a scrollable, centred hero + body with the call(s) to
|
|
||||||
* action pinned to the bottom (clear of the navigation bar). The content slot is
|
|
||||||
* centred horizontally; benefit rows fill the width so their own content
|
|
||||||
* left-aligns.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun PermissionScaffold(
|
|
||||||
hero: @Composable () -> Unit,
|
|
||||||
actions: @Composable ColumnScope.() -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
body: @Composable ColumnScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
bottomBar = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.navigationBarsPadding()
|
|
||||||
.padding(horizontal = Space.md, vertical = Space.sm),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
content = actions,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = Space.md),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Spacer(Modifier.height(Space.xl))
|
|
||||||
hero()
|
|
||||||
Spacer(Modifier.height(Space.lg))
|
|
||||||
body()
|
|
||||||
Spacer(Modifier.height(Space.md))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
|
|
||||||
@Composable
|
|
||||||
private fun BrandHero(denied: Boolean) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(128.dp)
|
|
||||||
.clip(RoundedCornerShape(34.dp))
|
|
||||||
.background(colorResource(R.color.ic_launcher_background)),
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
|
||||||
contentDescription = stringResource(R.string.app_name),
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (denied) {
|
|
||||||
// A small lock badge sits over the corner to signal "blocked".
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.offset(x = 10.dp, y = 10.dp)
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.errorContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Lock,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
|
|
||||||
@Composable
|
|
||||||
private fun BenefitRow(icon: ImageVector, title: String, body: String) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.secondaryContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(Space.sm))
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text(
|
|
||||||
text = body,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PrivacyFootnote() {
|
private fun PrivacyFootnote() {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.NotificationsActive
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Tune
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time onboarding step after the calendar grant (v1.4): explains that
|
||||||
|
* Calendula delivers reminder notifications itself, warns about duplicates
|
||||||
|
* when a second calendar app has notifications on, and requests
|
||||||
|
* `POST_NOTIFICATIONS` (a system dialog on API 33+ only; minSdk is 29).
|
||||||
|
*
|
||||||
|
* Reminders default ON: [onFinished] gets true from the primary action even
|
||||||
|
* if the system dialog is declined — the OS permission is the real gate, and
|
||||||
|
* the Settings toggle re-requests it. "Not now" turns the in-app toggle off.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ReminderOnboardingScreen(
|
||||||
|
onFinished: (remindersEnabled: Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { onFinished(true) }
|
||||||
|
|
||||||
|
OnboardingScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
hero = { BellHero() },
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
} else {
|
||||||
|
onFinished(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.reminder_onboarding_enable_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = { onFinished(false) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.reminder_onboarding_skip_button))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.app_name).uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.xs))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.reminder_onboarding_title),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.reminder_onboarding_body),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||||
|
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.NotificationsActive,
|
||||||
|
title = stringResource(R.string.reminder_benefit_delivery_title),
|
||||||
|
body = stringResource(R.string.reminder_benefit_delivery_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.ContentCopy,
|
||||||
|
title = stringResource(R.string.reminder_benefit_duplicates_title),
|
||||||
|
body = stringResource(R.string.reminder_benefit_duplicates_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.Tune,
|
||||||
|
title = stringResource(R.string.reminder_benefit_reversible_title),
|
||||||
|
body = stringResource(R.string.reminder_benefit_reversible_body),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bell in the brand squircle — same silhouette as the permission hero. */
|
||||||
|
@Composable
|
||||||
|
private fun BellHero() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(34.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.NotificationsActive,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gates the one-time reminder onboarding step (v1.4) shown after the calendar
|
||||||
|
* grant. [onboardingDone] is null until DataStore's first emission so the
|
||||||
|
* step neither flashes for users who completed it nor gets skipped.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class ReminderOnboardingViewModel @Inject constructor(
|
||||||
|
private val prefs: SettingsPrefs,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
|
||||||
|
.map { done -> done as Boolean? }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Close the step, recording whether reminder notifications stay on. */
|
||||||
|
fun finish(remindersEnabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setRemindersEnabled(remindersEnabled)
|
||||||
|
prefs.setReminderOnboardingDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -42,6 +47,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
@@ -128,6 +134,13 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
|
SectionHeader(stringResource(R.string.settings_section_notifications))
|
||||||
|
RemindersRow(
|
||||||
|
checked = state.remindersEnabled,
|
||||||
|
onCheckedChange = viewModel::setRemindersEnabled,
|
||||||
|
)
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
SectionHeader(stringResource(R.string.settings_section_language))
|
SectionHeader(stringResource(R.string.settings_section_language))
|
||||||
LanguageRow()
|
LanguageRow()
|
||||||
@@ -249,6 +262,55 @@ private fun DynamicColorRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
|
||||||
|
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
|
||||||
|
* the pref is set either way; the OS permission is the real gate.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RemindersRow(
|
||||||
|
checked: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { /* The pref is already on; a denial just leaves the OS gate shut. */ }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_reminders),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_reminders_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
onCheckedChange(enabled)
|
||||||
|
val needsPermission = enabled &&
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
if (needsPermission) {
|
||||||
|
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AboutSection() {
|
private fun AboutSection() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|||||||
@@ -18,4 +18,6 @@ data class SettingsUiState(
|
|||||||
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
||||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
|
val remindersEnabled: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,13 +28,15 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.dynamicColor,
|
prefs.dynamicColor,
|
||||||
prefs.weekStart,
|
prefs.weekStart,
|
||||||
prefs.defaultFormFields,
|
prefs.defaultFormFields,
|
||||||
) { theme, dynamic, weekStart, formFields ->
|
prefs.remindersEnabled,
|
||||||
|
) { theme, dynamic, weekStart, formFields, reminders ->
|
||||||
SettingsUiState(
|
SettingsUiState(
|
||||||
themeMode = theme,
|
themeMode = theme,
|
||||||
dynamicColor = dynamic && dynamicColorAvailable,
|
dynamicColor = dynamic && dynamicColorAvailable,
|
||||||
dynamicColorAvailable = dynamicColorAvailable,
|
dynamicColorAvailable = dynamicColorAvailable,
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
defaultFormFields = formFields,
|
defaultFormFields = formFields,
|
||||||
|
remindersEnabled = reminders,
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -57,4 +59,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
||||||
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setRemindersEnabled(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|||||||
12
app/src/main/res/drawable/ic_notification.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Monochrome status-bar mark: Material "event" calendar glyph. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM19,19H5V8h14V19zM7,10h5v5H7V10z" />
|
||||||
|
</vector>
|
||||||
@@ -82,6 +82,16 @@
|
|||||||
<string name="event_edit_availability">Verfügbarkeit</string>
|
<string name="event_edit_availability">Verfügbarkeit</string>
|
||||||
<string name="event_edit_visibility">Sichtbarkeit</string>
|
<string name="event_edit_visibility">Sichtbarkeit</string>
|
||||||
|
|
||||||
|
<!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
|
||||||
|
<string name="event_edit_conflict_title">Termin wurde extern geändert</string>
|
||||||
|
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
|
||||||
|
<string name="event_edit_conflict_overwrite">Meine Änderungen speichern</string>
|
||||||
|
<string name="event_edit_conflict_overwrite_hint">Nur von dir bearbeitete Felder überschreiben die externe Änderung</string>
|
||||||
|
<string name="event_edit_conflict_discard">Meine Änderungen verwerfen</string>
|
||||||
|
<string name="event_edit_conflict_discard_hint">Der Termin bleibt, wie er jetzt ist</string>
|
||||||
|
<string name="event_edit_gone_title">Termin wurde gelöscht</string>
|
||||||
|
<string name="event_edit_gone_body">Dieser Termin wurde zwischenzeitlich gelöscht, etwa auf einem anderen Gerät. Deine Änderungen können nicht mehr gespeichert werden.</string>
|
||||||
|
|
||||||
<!-- Termin-Formular — Wiederholungs-Picker (v1.3) -->
|
<!-- Termin-Formular — Wiederholungs-Picker (v1.3) -->
|
||||||
<string name="event_edit_recurrence_none">Wiederholt sich nicht</string>
|
<string name="event_edit_recurrence_none">Wiederholt sich nicht</string>
|
||||||
<string name="event_edit_recurrence_custom">Benutzerdefiniert</string>
|
<string name="event_edit_recurrence_custom">Benutzerdefiniert</string>
|
||||||
@@ -159,10 +169,25 @@
|
|||||||
<!-- Geteilte Event-Strings -->
|
<!-- Geteilte Event-Strings -->
|
||||||
<string name="event_untitled">(Ohne Titel)</string>
|
<string name="event_untitled">(Ohne Titel)</string>
|
||||||
|
|
||||||
|
<!-- Erinnerungs-Benachrichtigungen (v1.4) -->
|
||||||
|
<string name="reminder_channel_name">Termin-Erinnerungen</string>
|
||||||
|
<string name="reminder_channel_description">Benachrichtigungen zu den Erinnerungszeiten deiner Termine</string>
|
||||||
|
<string name="reminder_onboarding_title">Keinen Termin mehr verpassen</string>
|
||||||
|
<string name="reminder_onboarding_body">Android zeigt Termin-Erinnerungen nicht von selbst — das muss eine Kalender-App tun. Überlass Calendula den Job.</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">Erinnerungen, zugestellt</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">Jede Erinnerung an deinen Terminen kommt pünktlich als Benachrichtigung an.</string>
|
||||||
|
<string name="reminder_benefit_duplicates_title">Noch eine zweite Kalender-App?</string>
|
||||||
|
<string name="reminder_benefit_duplicates_body">Wenn eine andere App ebenfalls Erinnerungen zeigt, siehst du sie doppelt — schalte sie dort oder hier ab.</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">Jederzeit änderbar</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">Der Schalter liegt in den Einstellungen unter Benachrichtigungen.</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Erinnerungen einschalten</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Später</string>
|
||||||
|
|
||||||
<!-- View-Switcher (M1) -->
|
<!-- View-Switcher (M1) -->
|
||||||
<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>
|
||||||
@@ -183,6 +208,9 @@
|
|||||||
<string name="settings_week_start_sunday">Sonntag</string>
|
<string name="settings_week_start_sunday">Sonntag</string>
|
||||||
<string name="settings_section_event_form">Termin-Formular</string>
|
<string name="settings_section_event_form">Termin-Formular</string>
|
||||||
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
||||||
|
<string name="settings_section_notifications">Benachrichtigungen</string>
|
||||||
|
<string name="settings_reminders">Termin-Erinnerungen</string>
|
||||||
|
<string name="settings_reminders_hint">Erinnerungen doppelt? Eine andere Kalender-App zeigt sie ebenfalls — schalte sie in einer der beiden ab.</string>
|
||||||
<string name="settings_section_language">Sprache</string>
|
<string name="settings_section_language">Sprache</string>
|
||||||
<string name="settings_language">App-Sprache</string>
|
<string name="settings_language">App-Sprache</string>
|
||||||
<string name="settings_language_auto">Systemstandard</string>
|
<string name="settings_language_auto">Systemstandard</string>
|
||||||
|
|||||||
@@ -83,6 +83,16 @@
|
|||||||
<string name="event_edit_availability">Availability</string>
|
<string name="event_edit_availability">Availability</string>
|
||||||
<string name="event_edit_visibility">Visibility</string>
|
<string name="event_edit_visibility">Visibility</string>
|
||||||
|
|
||||||
|
<!-- Event form — save conflict (v2.0) -->
|
||||||
|
<string name="event_edit_conflict_title">Event changed elsewhere</string>
|
||||||
|
<string name="event_edit_conflict_body">While you were editing, this event was changed — by sync or another app. What should happen to your changes?</string>
|
||||||
|
<string name="event_edit_conflict_overwrite">Save my changes</string>
|
||||||
|
<string name="event_edit_conflict_overwrite_hint">Only fields you edited overwrite the outside change</string>
|
||||||
|
<string name="event_edit_conflict_discard">Discard my changes</string>
|
||||||
|
<string name="event_edit_conflict_discard_hint">The event stays as it is now</string>
|
||||||
|
<string name="event_edit_gone_title">Event deleted</string>
|
||||||
|
<string name="event_edit_gone_body">This event was deleted in the meantime, for example on another device. Your changes can no longer be saved.</string>
|
||||||
|
|
||||||
<!-- Event form — recurrence picker (v1.3) -->
|
<!-- Event form — recurrence picker (v1.3) -->
|
||||||
<string name="event_edit_recurrence_none">Does not repeat</string>
|
<string name="event_edit_recurrence_none">Does not repeat</string>
|
||||||
<string name="event_edit_recurrence_custom">Custom</string>
|
<string name="event_edit_recurrence_custom">Custom</string>
|
||||||
@@ -160,10 +170,25 @@
|
|||||||
<!-- Shared event strings -->
|
<!-- Shared event strings -->
|
||||||
<string name="event_untitled">(No title)</string>
|
<string name="event_untitled">(No title)</string>
|
||||||
|
|
||||||
|
<!-- Reminder notifications (v1.4) -->
|
||||||
|
<string name="reminder_channel_name">Event reminders</string>
|
||||||
|
<string name="reminder_channel_description">Notifications at the reminder times of your events</string>
|
||||||
|
<string name="reminder_onboarding_title">Never miss an event</string>
|
||||||
|
<string name="reminder_onboarding_body">Android doesn\'t show event reminders by itself — a calendar app has to. Let Calendula take that job.</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">Reminders, delivered</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">Every reminder on your events arrives as a notification, right on time.</string>
|
||||||
|
<string name="reminder_benefit_duplicates_title">Using a second calendar app?</string>
|
||||||
|
<string name="reminder_benefit_duplicates_body">If another app also posts reminders, you\'ll see them twice — turn them off there or here.</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">Change it anytime</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">The switch lives in Settings, under Notifications.</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Turn on reminders</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Not now</string>
|
||||||
|
|
||||||
<!-- View switcher (M1) -->
|
<!-- View switcher (M1) -->
|
||||||
<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>
|
||||||
@@ -184,6 +209,9 @@
|
|||||||
<string name="settings_week_start_sunday">Sunday</string>
|
<string name="settings_week_start_sunday">Sunday</string>
|
||||||
<string name="settings_section_event_form">New event form</string>
|
<string name="settings_section_event_form">New event form</string>
|
||||||
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||||
|
<string name="settings_section_notifications">Notifications</string>
|
||||||
|
<string name="settings_reminders">Event reminders</string>
|
||||||
|
<string name="settings_reminders_hint">Seeing reminders twice? Another calendar app is posting them too — turn them off in one of the two.</string>
|
||||||
<string name="settings_section_language">Language</string>
|
<string name="settings_section_language">Language</string>
|
||||||
<string name="settings_language">App language</string>
|
<string name="settings_language">App language</string>
|
||||||
<string name="settings_language_auto">System default</string>
|
<string name="settings_language_auto">System default</string>
|
||||||
|
|||||||
@@ -100,6 +100,27 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.defaultFormFields.first()).containsExactly(EventFormField.Location)
|
assertThat(prefs.defaultFormFields.first()).containsExactly(EventFormField.Location)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reminders default to enabled, onboarding to not done`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.remindersEnabled.first()).isTrue()
|
||||||
|
assertThat(prefs.reminderOnboardingDone.first()).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reminders toggle round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setRemindersEnabled(false)
|
||||||
|
assertThat(prefs.remindersEnabled.first()).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reminder onboarding completes one-way`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setReminderOnboardingDone()
|
||||||
|
assertThat(prefs.reminderOnboardingDone.first()).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `explicit week-start prefs resolve regardless of locale`() {
|
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||||
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class ReminderTimeTextTest {
|
||||||
|
|
||||||
|
private val berlin = ZoneId.of("Europe/Berlin")
|
||||||
|
|
||||||
|
private fun millisAt(dateTime: LocalDateTime, zone: ZoneId): Long =
|
||||||
|
dateTime.atZone(zone).toInstant().toEpochMilli()
|
||||||
|
|
||||||
|
private fun utcMidnight(date: LocalDate): Long =
|
||||||
|
date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timed event on one day shows just the time range`() {
|
||||||
|
val text = reminderTimeText(
|
||||||
|
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin),
|
||||||
|
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin),
|
||||||
|
isAllDay = false,
|
||||||
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("09:30 – 10:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timed event crossing midnight includes both dates`() {
|
||||||
|
val text = reminderTimeText(
|
||||||
|
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin),
|
||||||
|
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin),
|
||||||
|
isAllDay = false,
|
||||||
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
)
|
||||||
|
assertThat(text).contains("11.06.2026")
|
||||||
|
assertThat(text).contains("12.06.2026")
|
||||||
|
assertThat(text).contains("23:30")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day single day shows one date, read in UTC`() {
|
||||||
|
val text = reminderTimeText(
|
||||||
|
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
||||||
|
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
|
||||||
|
isAllDay = true,
|
||||||
|
// Zone must not matter for all-day events: UTC midnight is
|
||||||
|
// 02:00 in Berlin — naive local reading would shift the day.
|
||||||
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("11.06.2026")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day multi-day shows the last covered day, not the exclusive end`() {
|
||||||
|
val text = reminderTimeText(
|
||||||
|
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
||||||
|
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
|
||||||
|
isAllDay = true,
|
||||||
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `degenerate all-day range never renders an inverted span`() {
|
||||||
|
val day = utcMidnight(LocalDate.of(2026, 6, 11))
|
||||||
|
val text = reminderTimeText(
|
||||||
|
beginMillis = day,
|
||||||
|
endMillis = day,
|
||||||
|
isAllDay = true,
|
||||||
|
zone = berlin,
|
||||||
|
locale = Locale.GERMANY,
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("11.06.2026")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,21 +112,24 @@ class EventFormTest {
|
|||||||
reminders: List<Reminder> = emptyList(),
|
reminders: List<Reminder> = emptyList(),
|
||||||
availability: Availability = Availability.Busy,
|
availability: Availability = Availability.Busy,
|
||||||
accessLevel: AccessLevel = AccessLevel.Default,
|
accessLevel: AccessLevel = AccessLevel.Default,
|
||||||
|
rowStart: Long = 0L,
|
||||||
|
rowEnd: Long = 0L,
|
||||||
|
attendees: List<Attendee> = emptyList(),
|
||||||
): EventDetail = EventDetail(
|
): EventDetail = EventDetail(
|
||||||
instance = EventInstance(
|
instance = EventInstance(
|
||||||
instanceId = 1L,
|
instanceId = 1L,
|
||||||
eventId = 1L,
|
eventId = 1L,
|
||||||
calendarId = 7L,
|
calendarId = 7L,
|
||||||
title = title,
|
title = title,
|
||||||
start = Instant.fromEpochMilliseconds(0L),
|
start = Instant.fromEpochMilliseconds(rowStart),
|
||||||
end = Instant.fromEpochMilliseconds(0L),
|
end = Instant.fromEpochMilliseconds(rowEnd),
|
||||||
isAllDay = isAllDay,
|
isAllDay = isAllDay,
|
||||||
color = 0,
|
color = 0,
|
||||||
location = location,
|
location = location,
|
||||||
),
|
),
|
||||||
description = description,
|
description = description,
|
||||||
organizer = null,
|
organizer = null,
|
||||||
attendees = emptyList(),
|
attendees = attendees,
|
||||||
rrule = rrule,
|
rrule = rrule,
|
||||||
reminders = reminders,
|
reminders = reminders,
|
||||||
availability = availability,
|
availability = availability,
|
||||||
@@ -177,6 +180,41 @@ class EventFormTest {
|
|||||||
assertThat(prefilled.rrule).isEqualTo("FREQ=WEEKLY")
|
assertThat(prefilled.rrule).isEqualTo("FREQ=WEEKLY")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `snapshots of an unchanged event are equal`() {
|
||||||
|
val a = detail().toEditSnapshot(0L, 3_600_000L, berlin)
|
||||||
|
val b = detail().toEditSnapshot(0L, 3_600_000L, berlin)
|
||||||
|
assertThat(b).isEqualTo(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an external field change makes snapshots differ`() {
|
||||||
|
val loaded = detail().toEditSnapshot(0L, 3_600_000L, berlin)
|
||||||
|
val fresh = detail(title = "Stand-up (moved)").toEditSnapshot(0L, 3_600_000L, berlin)
|
||||||
|
assertThat(fresh).isNotEqualTo(loaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an external time move is caught by the row times the form cannot see`() {
|
||||||
|
// Both snapshots are taken for the same tapped occurrence, so the
|
||||||
|
// *forms* derive identical times — only rowStart/rowEnd betray the move.
|
||||||
|
val loaded = detail(rrule = "FREQ=WEEKLY", rowStart = 0L)
|
||||||
|
.toEditSnapshot(0L, 3_600_000L, berlin)
|
||||||
|
val fresh = detail(rrule = "FREQ=WEEKLY", rowStart = 86_400_000L)
|
||||||
|
.toEditSnapshot(0L, 3_600_000L, berlin)
|
||||||
|
assertThat(fresh.form).isEqualTo(loaded.form)
|
||||||
|
assertThat(fresh).isNotEqualTo(loaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `changes the form cannot write do not fake a conflict`() {
|
||||||
|
val loaded = detail().toEditSnapshot(0L, 3_600_000L, berlin)
|
||||||
|
val fresh = detail(
|
||||||
|
attendees = listOf(Attendee("Ada", "ada@example.org", AttendeeStatus.Accepted)),
|
||||||
|
).toEditSnapshot(0L, 3_600_000L, berlin)
|
||||||
|
assertThat(fresh).isEqualTo(loaded)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `populatedFields reports exactly the sections holding values`() {
|
fun `populatedFields reports exactly the sections holding values`() {
|
||||||
val empty = form().copy(location = "", description = "")
|
val empty = form().copy(location = "", description = "")
|
||||||
|
|||||||
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
@@ -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
@@ -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`.
|
||||||
@@ -47,8 +47,8 @@ Domain bleibt pure Kotlin.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| v1.1 | Write-Fundament: `WRITE_CALENDAR`, `canModifyContents`, Delete (Serie + einzelnes Vorkommen) | ausgeliefert (v1.1.0, 2026-06-11) |
|
| v1.1 | Write-Fundament: `WRITE_CALENDAR`, `canModifyContents`, Delete (Serie + einzelnes Vorkommen) | ausgeliefert (v1.1.0, 2026-06-11) |
|
||||||
| v1.2 | Create: Event-Formular (Titel, Kalender, ganztägig, Start/Ende, Ort, Beschreibung), FAB, Default-Kalender-Pref | ausgeliefert (v1.2.0, 2026-06-11) |
|
| v1.2 | Create: Event-Formular (Titel, Kalender, ganztägig, Start/Ende, Ort, Beschreibung), FAB, Default-Kalender-Pref | ausgeliefert (v1.2.0, 2026-06-11) |
|
||||||
| v1.3 | Edit: Formular wiederverwendet, Serien-Edit, Reminder-Edit, einfacher Recurrence-Picker | implementiert (Release wartet auf On-Device-Review) |
|
| v1.3 | Edit: Formular wiederverwendet, Serien-Edit, Reminder-Edit, einfacher Recurrence-Picker | ausgeliefert (v1.3.0, 2026-06-11) |
|
||||||
| v2.0 | Quick-Add, Occurrence-Edit, Konflikt-Dialog, Polish-Pass, Release | offen |
|
| v2.0 | Konflikt-Dialog, Polish-Pass (Store-Copy, Screenshots), Release | ausgeliefert (v2.0.0, 2026-06-11) |
|
||||||
|
|
||||||
## v1.1 — Write-Fundament + Delete
|
## v1.1 — Write-Fundament + Delete
|
||||||
|
|
||||||
@@ -180,9 +180,25 @@ Domain bleibt pure Kotlin.
|
|||||||
Bewusst nicht in v1.3 (→ v2.0): Konflikt-Dialog, Kalender-Wechsel beim
|
Bewusst nicht in v1.3 (→ v2.0): Konflikt-Dialog, Kalender-Wechsel beim
|
||||||
Bearbeiten (Sync-Adapter-Minenfeld, sperren auch alle Stock-Apps).
|
Bearbeiten (Sync-Adapter-Minenfeld, sperren auch alle Stock-Apps).
|
||||||
|
|
||||||
## v2.0 — Abschluss (Skizze)
|
## v2.0 — Abschluss (Scope-Recut 2026-06-11, nach v1.4)
|
||||||
|
|
||||||
- Quick-Add-Sheet (Titel + Zeit, Rest Defaults)
|
- ~~Quick-Add-Sheet (Titel + Zeit, Rest Defaults)~~ — **gestrichen**: das
|
||||||
- Occurrence-Edit (Exception mit geänderten Werten)
|
Formular öffnet bereits vorbefüllt (sichtbarer Tag, zuletzt benutzter
|
||||||
- Konflikt-Dialog beim Speichern
|
Kalender, optionale Felder versteckt); der Sheet spart nur einen
|
||||||
- Changelog, F-Droid-Metadaten, Release-Tag
|
Screen-Übergang und kostet eine zweite Create-Surface. Nur bei
|
||||||
|
Praxis-Feedback wieder aufnehmen
|
||||||
|
- ~~Occurrence-Edit (Exception mit geänderten Werten)~~ — schon in v1.3
|
||||||
|
ausgeliefert (vorgezogen)
|
||||||
|
- [x] Konflikt-Dialog beim Speichern (Leitentscheidung 5): `EditSnapshot`
|
||||||
|
(Formular + rohe Row-Zeiten) wird beim Laden gemerkt und vor dem
|
||||||
|
Schreiben gegen einen frischen Read verglichen; Abweichung parkt den
|
||||||
|
Save in `AwaitingConflict` (Überschreiben/Verwerfen/Abbrechen,
|
||||||
|
OptionCard-Stil), gelöschtes Event → `Gone`-Dialog. "Überschreiben"
|
||||||
|
schreibt weiterhin nur dirty Felder
|
||||||
|
- Kalender-Wechsel beim Bearbeiten → v3-Backlog (copy+delete-Modell)
|
||||||
|
- [x] Polish: F-Droid-Description + README auf Write-Support + Reminder
|
||||||
|
aktualisiert (DE+EN)
|
||||||
|
- [x] F-Droid-Screenshots (de-DE + en-US, je 6: Woche/Monat/Tag/Detail/
|
||||||
|
Formular/Onboarding) — mit Demo-Kalendern auf dem Gerät aufgenommen
|
||||||
|
- [x] Changelog, Release-Tag v2.0.0 (ausgeliefert 2026-06-11 — Milestone 2
|
||||||
|
damit abgeschlossen)
|
||||||
|
|||||||
119
docs/superpowers/plans/2026-06-11-04-reminder-notifications.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Calendula - Plan 04: Reminder Notifications (v1.4)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Calendula stellt Erinnerungen selbst als Notification zu (Etar-Modell).
|
||||||
|
Der Provider plant die Alarme und broadcastet
|
||||||
|
`android.intent.action.EVENT_REMINDER` — die sichtbare Notification postet er
|
||||||
|
**nicht**, das muss eine Kalender-App tun. Für Nutzer, deren einzige
|
||||||
|
Kalender-App Calendula ist, ist das essenziell, kein Nice-to-have.
|
||||||
|
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
|
||||||
|
On-Device-Review.
|
||||||
|
|
||||||
|
**Architecture:** Eigenes kleines Datenmodul `data/reminders/` neben
|
||||||
|
`data/calendar/` — der Receiver braucht weder Repository noch Flows. Schichtung
|
||||||
|
wie gehabt: `EventReminderReceiver` (Hilt-EntryPoint) →
|
||||||
|
`ReminderAlertStore` (Interface, Android-Impl auf `CalendarAlerts`) →
|
||||||
|
`ReminderNotifier` (NotificationManager). Domain bleibt pure Kotlin
|
||||||
|
(`ReminderAlert`-Modell, JVM-testbare Textformatierung).
|
||||||
|
|
||||||
|
**Recherche-Befunde (AOSP `CalendarAlarmManager` + Etar, 2026-06-11):**
|
||||||
|
|
||||||
|
1. Der Provider legt `CalendarAlerts`-Rows **nur für `METHOD_ALERT`-Reminder**
|
||||||
|
an (AOSP-Query: `AND method=1`). Der im Roadmap-Eintrag geforderte
|
||||||
|
METHOD-Filter (E-Mail überspringen) passiert also schon upstream — wir
|
||||||
|
filtern nicht doppelt. Calendula schreibt eigene Reminder ohnehin als
|
||||||
|
`METHOD_ALERT`.
|
||||||
|
2. Der Broadcast ist implizit (Action + `content://com.android.calendar/…`-URI,
|
||||||
|
Extra `alarmTime`). Etars Manifest-Receiver ist `exported="true"` mit
|
||||||
|
`<data android:scheme="content"/>` — das übernehmen wir (plus Host,
|
||||||
|
enger gefasst). Den URI-Inhalt werten wir nicht aus; wir queryen selbst
|
||||||
|
"fällig & noch SCHEDULED".
|
||||||
|
3. Etar postet aus dem Zustand `SCHEDULED ∪ FIRED` und verwaltet Dismiss über
|
||||||
|
eigene Services. Wir vereinfachen: nur `STATE_SCHEDULED AND alarmTime <= now`
|
||||||
|
posten, danach best-effort auf `FIRED` setzen (braucht `WRITE_CALENDAR`;
|
||||||
|
`SecurityException` wird geschluckt). Weggewischte Notifications kommen so
|
||||||
|
nie wieder, ohne deleteIntent-Maschinerie: FIRED-Rows fassen wir nicht an.
|
||||||
|
Re-Broadcasts ohne Write-Recht ersetzen still (Tag pro Alert +
|
||||||
|
`setOnlyAlertOnce`).
|
||||||
|
|
||||||
|
**Leitentscheidungen:**
|
||||||
|
|
||||||
|
1. **Kein eigenes Alarm-Scheduling** (kein `SCHEDULE_EXACT_ALARM`, kein
|
||||||
|
`BOOT_COMPLETED`, kein WorkManager): Zustellung hängt am Provider-Broadcast.
|
||||||
|
Etars Zusatz-Maschinerie (eigener AlarmScheduler) kommt erst, wenn sich
|
||||||
|
Zuverlässigkeit auf echten Geräten als Problem zeigt (Roadmap: bewusst
|
||||||
|
verschoben, ebenso Snooze-/Dismiss-Actions und Battery-Exemption).
|
||||||
|
2. **Toggle default ON, Onboarding-Schritt danach:** Nach dem Kalender-Grant
|
||||||
|
folgt ein zweiter Onboarding-Screen (gleiche Shell wie der Permission-
|
||||||
|
Screen): erklärt Reminder, warnt vor Duplikaten (zweite Kalender-App mit
|
||||||
|
aktiven Notifications), fragt `POST_NOTIFICATIONS` an (nur API 33+ zeigt
|
||||||
|
einen Dialog; minSdk 29). "Später" schaltet den Toggle aus. Der Schritt
|
||||||
|
erscheint genau einmal (`reminder_onboarding_done`-Pref) — auch für
|
||||||
|
v1.0–v1.3-Upgrader, die das Feature so entdecken.
|
||||||
|
3. **Settings-Spiegel:** Abschnitt "Erinnerungen" mit demselben Toggle +
|
||||||
|
Duplikat-Hinweis. Einschalten fordert `POST_NOTIFICATIONS` kontextuell an,
|
||||||
|
wenn sie fehlt.
|
||||||
|
4. **Tap öffnet das Event-Detail:** Notification-Intent trägt
|
||||||
|
eventId/begin/end; `MainActivity` wird `singleTop`, reicht den Key als
|
||||||
|
Compose-State an `CalendarHost` durch (gleiches LongArray-Key-Muster wie
|
||||||
|
der Detail-Overlay selbst).
|
||||||
|
5. **Ein Kanal, einfache Inhalte:** Kanal "Erinnerungen"
|
||||||
|
(`IMPORTANCE_HIGH`), pro Alert eine Notification (Tag = Alert-Id):
|
||||||
|
Titel = Eventtitel (Fallback "(Ohne Titel)"), Text = Zeitspanne
|
||||||
|
(ganztägig: Datum, UTC gelesen) + Ort. Kein Grouping/Summary, kein
|
||||||
|
Vollbild-Alarm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
**Manifest / Resourcen:**
|
||||||
|
- [x] `POST_NOTIFICATIONS` ins Manifest; Receiver `.reminders.EventReminderReceiver`
|
||||||
|
`exported="true"`, Intent-Filter `EVENT_REMINDER` + `data scheme=content
|
||||||
|
host=com.android.calendar`; `MainActivity` → `launchMode="singleTop"`
|
||||||
|
- [x] Monochromes Notification-Icon `drawable/ic_notification.xml`
|
||||||
|
- [x] Strings DE+EN: Kanal, Onboarding-Copy, Settings-Abschnitt + Hinweis
|
||||||
|
|
||||||
|
**Prefs:**
|
||||||
|
- [x] `SettingsPrefs.remindersEnabled` (default **true**) + Setter;
|
||||||
|
`reminderOnboardingDone` (default false) + Setter; `SettingsPrefsTest`
|
||||||
|
|
||||||
|
**Data layer (`data/reminders/`):**
|
||||||
|
- [x] `ReminderAlert`-Modell (in `data/reminders/`, nicht domain — Alerts
|
||||||
|
erreichen nie einen Screen): alertId, eventId, begin/end als Millis,
|
||||||
|
title, location, isAllDay
|
||||||
|
- [x] `ReminderAlertStore` (Interface) + `AndroidReminderAlertStore`:
|
||||||
|
`dueAlerts(nowMillis)` = `CalendarAlerts` mit
|
||||||
|
`STATE_SCHEDULED AND ALARM_TIME <= now`;
|
||||||
|
`markFired(ids, nowMillis)` setzt STATE/RECEIVED_TIME/NOTIFY_TIME,
|
||||||
|
`SecurityException` → Log (Write-Recht optional)
|
||||||
|
- [x] `ReminderNotifier`: Kanal lazy anlegen, eine Notification pro Alert
|
||||||
|
(Tag = alertId, `setOnlyAlertOnce`, autoCancel, `when` = begin,
|
||||||
|
Category EVENT), Content-PendingIntent auf `MainActivity` mit
|
||||||
|
eventId/begin/end
|
||||||
|
- [x] Zeitspannen-Text als pure Funktion (JVM-testbar) + Test
|
||||||
|
|
||||||
|
**Receiver:**
|
||||||
|
- [x] `EventReminderReceiver` (`@AndroidEntryPoint`): Action prüfen,
|
||||||
|
`goAsync()`; raus, wenn Pref aus, READ_CALENDAR fehlt oder
|
||||||
|
Notifications systemseitig geblockt; sonst posten → `markFired`
|
||||||
|
|
||||||
|
**UI:**
|
||||||
|
- [x] Onboarding-Shell aus `PermissionScreen` extrahieren
|
||||||
|
(`OnboardingScaffold` + BenefitRow, intern wiederverwendet)
|
||||||
|
- [x] `NotificationOnboardingScreen` + ViewModel: Benefit-Rows (verpasst
|
||||||
|
nichts / Duplikat-Warnung), Primär-Button fordert `POST_NOTIFICATIONS`
|
||||||
|
(API 33+) und lässt den Toggle an, "Später" schaltet ihn aus; beide
|
||||||
|
setzen `reminder_onboarding_done`
|
||||||
|
- [x] `RootScreen`: Kalender-Gate → Reminder-Schritt (einmalig) → `CalendarHost`
|
||||||
|
- [x] `CalendarHost`: externer Detail-Key (Notification-Tap) wird wie ein
|
||||||
|
Event-Tap konsumiert; `MainActivity` parst Intent (onCreate +
|
||||||
|
onNewIntent) in Compose-State
|
||||||
|
- [x] Settings: Abschnitt "Benachrichtigungen" — Toggle (mit kontextuellem
|
||||||
|
Permission-Request beim Einschalten) + Duplikat-Hinweistext
|
||||||
|
|
||||||
|
**Abschluss:**
|
||||||
|
- [x] `./gradlew lint test assembleDebug` grün
|
||||||
|
- [x] CHANGELOG (`[Unreleased]`), ROADMAP-Status; **kein** Tag/Release vor
|
||||||
|
On-Device-Review
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
Calendula ist eine moderne, quelloffene Kalender-App für Android. Sie liest
|
Calendula ist eine moderne, quelloffene Kalender-App für Android. Sie
|
||||||
direkt aus dem System-Kalender-Provider — jede Quelle, die mit deinem Gerät
|
arbeitet direkt auf dem System-Kalender-Provider — jede Quelle, die mit
|
||||||
synchronisiert ist (Nextcloud über DAVx5, Google, lokal, WebCal-Subscriptions)
|
deinem Gerät synchronisiert ist (Nextcloud über DAVx5, Google, lokal,
|
||||||
erscheint automatisch.
|
WebCal-Subscriptions), erscheint automatisch, und deine Änderungen
|
||||||
|
synchronisieren auf demselben Weg zurück.
|
||||||
|
|
||||||
|
Termine erstellen, bearbeiten und löschen — auch wiederkehrende, mit
|
||||||
|
wählbarer Reichweite (nur dieser Termin / dieser und alle folgenden / ganze
|
||||||
|
Serie) und einem einfachen Wiederholungs-Picker. Erinnerungen stellt
|
||||||
|
Calendula selbst als Benachrichtigung zu — ein Tipp darauf öffnet den
|
||||||
|
Termin.
|
||||||
|
|
||||||
Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend,
|
Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend,
|
||||||
mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen.
|
mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen.
|
||||||
|
|
||||||
V1 ist read-only. Erstellen, Bearbeiten und Löschen von Events kommt mit V2.
|
Datenschutz: keinerlei Telemetrie, kein Tracking, kein Netzwerkzugriff —
|
||||||
|
deine Daten bleiben auf dem Gerät.
|
||||||
Datenschutz: keinerlei Telemetrie, kein Tracking, kein Netzwerkzugriff — deine
|
|
||||||
Daten bleiben auf dem Gerät.
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 156 KiB |
@@ -1,11 +1,15 @@
|
|||||||
Calendula is a modern, open-source calendar app for Android. It reads from
|
Calendula is a modern, open-source calendar app for Android. It works
|
||||||
the system calendar provider, so any source synced to your device — Nextcloud
|
directly on the system calendar provider, so any source synced to your
|
||||||
via DAVx5, Google, local, WebCal subscriptions — shows up automatically.
|
device — Nextcloud via DAVx5, Google, local, WebCal subscriptions — shows up
|
||||||
|
automatically, and changes you make sync back the same way.
|
||||||
|
|
||||||
The differentiator is the design: real Material 3 Expressive throughout, with
|
Create, edit and delete events, including recurring events with scoped
|
||||||
dynamic color, expressive motion, and expressive shapes.
|
changes (only this event / this and all following / the whole series) and a
|
||||||
|
simple repeat picker. Calendula also delivers your event reminders as
|
||||||
|
notifications — tap one and you're on the event.
|
||||||
|
|
||||||
V1 is read-only. Event creation, editing, and deletion are planned for V2.
|
The differentiator is the design: real Material 3 Expressive throughout,
|
||||||
|
with dynamic color, expressive motion, and expressive shapes.
|
||||||
|
|
||||||
Privacy: zero telemetry, no analytics, no network access — your data never
|
Privacy: zero telemetry, no analytics, no network access — your data never
|
||||||
leaves the device.
|
leaves the device.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 135 KiB |