Compare commits

..

1 Commits

Author SHA1 Message Date
54739a5348 chore(deps): update kotlin to v2.4.0
Some checks failed
CI / ci (push) Failing after 3m22s
2026-06-19 09:17:54 +00:00
104 changed files with 497 additions and 4374 deletions

View File

@@ -1,23 +0,0 @@
---
name: Bug report
about: Something doesn't work the way it should
title: ""
labels:
- bug
---
### What happened
### What you expected
### Steps to reproduce
1.
2.
3.
### Environment
- Calendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:

View File

@@ -1,26 +0,0 @@
---
name: Crash report
about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form.
title: "Crash: "
labels:
- bug
- crash
---
<!--
Thanks for reporting a crash in Calendula!
If the app prefilled this for you, the crash report is already below — just add
what you were doing and submit. Otherwise, paste the report from your clipboard
into the code block. The report contains only app/Android/device versions and the
stack trace — no personal data or calendar content.
-->
### What happened
### Crash report
```
(paste the crash report here)
```

View File

@@ -1,16 +0,0 @@
---
name: Feature request
about: Suggest an idea or improvement
title: ""
labels:
- enhancement
---
### What would you like Calendula to do?
### Why — what problem does it solve?
### Anything else
<!-- mockups, examples from other apps, alternatives you considered -->

View File

@@ -1,23 +1,18 @@
name: CI
# One gate per pull request. Branch pushes no longer trigger CI on their own,
# so a change is built once on its PR (covering feature -> release/* and
# release/* -> main) instead of once per push and again on the merge to main.
# The merge itself is handled by release.yaml, which only does heavy work when
# the merge actually cuts a release.
on:
pull_request:
push:
branches:
- '**'
tags-ignore:
- '**'
# Cancel superseded runs for the same PR.
# Cancel superseded runs on the same branch.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
# Single job named `ci` so the required "CI" status check is always reported,
# even for docs-only PRs: those just skip the Android build and the job still
# succeeds (fast green check) instead of being filtered out and leaving the
# required check pending forever.
ci:
runs-on: docker
env:
@@ -26,53 +21,14 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history so the base..HEAD diff below has a merge-base.
fetch-depth: 0
# Cheap, always-on guard: the release build must stay reproducible for the
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
# change scope so a regression can't slip through on a "docs-only" PR.
- name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh
# Decide whether anything that affects the app build changed. Docs,
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
# Gradle work below but still report a green `ci`.
- name: Classify change scope
id: scope
run: |
set -e
BASE="${{ github.base_ref }}"
# Full (not --depth=1) base fetch so the merge-base is present even when
# the PR branch forked several commits back; a shallow tip has no merge
# base with a divergent branch and `git diff base...HEAD` aborts.
git fetch --no-tags origin "$BASE"
MB=$(git merge-base "origin/$BASE" HEAD 2>/dev/null || true)
if [ -z "$MB" ]; then
# No common ancestor available — don't risk skipping the build.
echo "No merge base with origin/$BASE — running the full build to be safe."
echo "code=true" >> "$GITHUB_OUTPUT"
exit 0
fi
CHANGED=$(git diff --name-only "$MB" HEAD)
echo "Changed files:"; echo "$CHANGED"
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then
echo "code=true" >> "$GITHUB_OUTPUT"
else
echo "code=false" >> "$GITHUB_OUTPUT"
echo "Docs/metadata-only change — skipping the Android build."
fi
- name: Setup Java
if: steps.scope.outputs.code == 'true'
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Android SDK
if: steps.scope.outputs.code == 'true'
uses: android-actions/setup-android@v3
with:
# Default ("tools platform-tools") drags in the Android Emulator
@@ -80,14 +36,12 @@ jobs:
packages: ''
- name: Setup Android SDK cache
if: steps.scope.outputs.code == 'true'
uses: actions/cache@v4
with:
path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages
if: steps.scope.outputs.code == 'true'
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
@@ -96,7 +50,6 @@ jobs:
"build-tools;36.0.0"
- name: Setup Gradle cache
if: steps.scope.outputs.code == 'true'
uses: actions/cache@v4
with:
path: |
@@ -107,25 +60,21 @@ jobs:
${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew
if: steps.scope.outputs.code == 'true'
run: chmod +x ./gradlew
# No --no-daemon: the daemon lives only as long as this job container
# and lets the following steps skip JVM startup + reconfiguration.
- name: Lint (debug variant only)
if: steps.scope.outputs.code == 'true'
run: ./gradlew lintDebug
- name: Unit tests
if: steps.scope.outputs.code == 'true'
run: ./gradlew testDebugUnitTest
- name: Assemble debug APK
if: steps.scope.outputs.code == 'true'
run: ./gradlew assembleDebug
- name: Trivy filesystem scan
if: steps.scope.outputs.code == 'true'
if: github.ref == 'refs/heads/main'
run: |
set -e
SUDO=""

View File

@@ -1,84 +1,75 @@
name: Release — F-Droid repo + Gitea release
# A release is cut by merging a release branch into main with a bumped
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
# it to the F-Droid repo, and only then creates the vX.Y.Z tag + Gitea release
# itself — the tag is an output of the pipeline, not its trigger. Ordinary
# merges (no version bump) fall through `detect` and do nothing.
#
# A manual workflow_dispatch (from a branch) runs the re-sign-only recovery
# path: it re-signs the existing F-Droid index with the repo key and re-uploads,
# without building an APK or creating a release. Used for key rotation / repo
# recovery.
on:
push:
branches: [main]
tags:
- '*'
workflow_dispatch:
concurrency:
group: release
cancel-in-progress: false
jobs:
# Cheap gate: resolve the version from the committed build.gradle and decide
# whether this push actually cuts a new release (no tag for it yet). Keeps the
# heavy job from running on every merge to main.
detect:
runs-on: docker
outputs:
is_release: ${{ steps.v.outputs.is_release }}
version: ${{ steps.v.outputs.version }}
version_code: ${{ steps.v.outputs.version_code }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve version and whether it is a new release
id: v
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
echo "Resolved version $VERSION (code $VERSION_CODE)"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "Manual dispatch — re-sign path, not a release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# A tag for this version already existing means the release shipped on
# an earlier push; do nothing. Absent => this merge cuts the release.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION")
if [ "$STATUS" = "200" ]; then
echo "Tag v$VERSION already exists — nothing to release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
else
echo "No tag for v$VERSION yet — cutting the release."
echo "is_release=true" >> "$GITHUB_OUTPUT"
fi
# Releases: build + sign + publish, then mint the tag and Gitea release.
# Also runs on manual dispatch, where it skips the build and just re-signs and
# re-uploads the existing index (recovery path).
release:
needs: detect
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
ci:
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: ''
- name: Setup Android SDK cache
uses: actions/cache@v4
with:
path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
"platform-tools" \
"platforms;android-37.0" \
"build-tools;36.0.0"
- name: Setup Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
# Lint already enforced on every push to main via ci.yaml.
# Release sanity only re-runs tests + a debug build to catch
# any tag-resolved drift (e.g. version code substitution issues).
- name: Unit tests
run: ./gradlew testDebugUnitTest
- name: Assemble debug APK (sanity)
run: ./gradlew assembleDebug
build-and-deploy:
needs: ci
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
IS_RELEASE: ${{ needs.detect.outputs.is_release }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -130,26 +121,31 @@ jobs:
$SUDO apk add --no-cache jq
fi
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
# The committed versionName is the source of truth. Pin versionCode to the
# value derived from it so the published APK's code is always
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
- name: Pin versionCode to versionName
if: env.IS_RELEASE == 'true'
# Tag-only build steps. On a manual workflow_dispatch (ref = a branch,
# not a tag) these are skipped: the job then just re-signs the existing
# index with the configured repo key and re-uploads — used for key
# rotation / repo recovery without publishing a new APK.
- name: Set version from git tag
if: startsWith(github.ref, 'refs/tags/')
run: |
set -e
RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
VERSION="${RAW_TAG#v}"
MAJOR=$(echo "$VERSION" | cut -d. -f1)
MINOR=$(echo "$VERSION" | cut -d. -f2)
PATCH=$(echo "$VERSION" | cut -d. -f3)
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "Version: $VERSION, VersionCode: $VERSION_CODE"
sed -i "s/versionName = \".*\"/versionName = \"$VERSION\"/" app/build.gradle.kts
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
grep -E 'versionName|versionCode' app/build.gradle.kts
# Test the exact commit being shipped (only on a real release).
- name: Unit tests
if: env.IS_RELEASE == 'true'
run: ./gradlew testDebugUnitTest
# Export for later steps (F-Droid changelog, mapping asset name).
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "VERSION_CODE=$VERSION_CODE" >> "$GITHUB_ENV"
- name: Setup Android Keystore
if: env.IS_RELEASE == 'true'
if: startsWith(github.ref, 'refs/tags/')
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
@@ -164,8 +160,11 @@ jobs:
storeFile=upload-keystore.jks
EOF
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Build release APK
if: env.IS_RELEASE == 'true'
if: startsWith(github.ref, 'refs/tags/')
run: ./gradlew assembleRelease
- name: Setup F-Droid Server Tools
@@ -203,7 +202,8 @@ jobs:
set -euo pipefail
# Fail loudly if the repo key is not configured. NEVER auto-generate
# one: a fresh key changes the repo fingerprint and breaks every
# user's pinned repo.
# 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
@@ -216,33 +216,42 @@ jobs:
mkdir -p fdroid/repo/icons
- name: Copy new APK to repo
if: env.IS_RELEASE == 'true'
if: startsWith(github.ref, 'refs/tags/')
run: |
set -e
mkdir -p fdroid/repo
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
REF_NAME="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
SAFE_REF_NAME="$(echo "$REF_NAME" | tr '/ ' '__' | tr -cd '[:alnum:]_.-')"
if [ -z "$SAFE_REF_NAME" ]; then
SAFE_REF_NAME="${GITHUB_SHA:-manual}"
fi
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_${SAFE_REF_NAME}.apk"
# Per-version "What's New": ensure this version's changelog exists in the
# fastlane tree (committed at release-cut time for the official repo; this
# regenerates it from CHANGELOG.md so the self-hosted repo never depends on
# the commit having happened). The transform below then carries it across.
- name: Ensure this version's changelog is in the fastlane tree
if: env.IS_RELEASE == 'true'
run: bash scripts/sync_changelog_to_fastlane.sh
- name: Build F-Droid metadata from fastlane (single source of truth)
- name: Copy metadata to F-Droid repo
run: |
mkdir -p fdroid/metadata
# App-level control file (Categories/License/links) for the self-hosted
# repo's `fdroid update`.
cp fdroid-metadata/de.jeanlucmakiola.calendula.yml fdroid/metadata/
# Localized text + graphics + per-version changelogs come from the SAME
# fastlane tree the official F-Droid repo harvests from source,
# transformed into the F-Droid repo "localized" layout. One source of
# truth, both channels.
bash scripts/fastlane_to_fdroid_localized.sh \
fastlane/metadata/android \
fdroid/metadata/de.jeanlucmakiola.calendula
cp -r fdroid-metadata/* fdroid/metadata/
# Per-version "What's New" for F-Droid clients: the tag's CHANGELOG
# section written to changelogs/<versionCode>.txt (same extraction as the
# Gitea release notes). en-US only — F-Droid falls back to it for locales
# without their own changelog. fdroid update bakes this into the index.
- name: Generate F-Droid changelog for this version
if: startsWith(github.ref, 'refs/tags/')
run: |
set -e
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > /tmp/changelog.txt
sed -i -e '/./,$!d' /tmp/changelog.txt
if [ ! -s /tmp/changelog.txt ]; then
echo "See CHANGELOG.md for $VERSION." > /tmp/changelog.txt
fi
CL_DIR="fdroid/metadata/de.jeanlucmakiola.calendula/en-US/changelogs"
mkdir -p "$CL_DIR"
cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt"
echo "Wrote $CL_DIR/${VERSION_CODE}.txt"
- name: Generate F-Droid Index
run: |
@@ -263,46 +272,97 @@ jobs:
SFTP
# 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.
# 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/"
# The APK is published and the index re-signed — now record the release.
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
# this commit, so the tag only ever marks a fully-shipped release (and a
# failure before here leaves no tag, so re-running the workflow retries).
- name: Create tag + Gitea release
if: env.IS_RELEASE == 'true'
# 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 }}
SHA: ${{ github.sha }}
run: |
set -e
TAG="v$VERSION"
# Notes = this version's CHANGELOG section.
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
python3 - "$TAG" "$SHA" <<'PY' > payload.json
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],
"target_commitish": sys.argv[2],
"name": sys.argv[1],
"body": open("release-notes.md").read(),
"draft": False,
"prerelease": False,
}))
PY
# Upsert (re-run safe): PATCH if a release for the tag already exists,
# else POST a new one (which also creates the tag at target_commitish).
# 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=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
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" \
@@ -316,33 +376,6 @@ jobs:
fi
cat response.json
if [ "$CODE" != "$OK" ]; then
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
echo "Release upsert failed with HTTP $CODE (expected $OK)"
exit 1
fi
echo "Created/updated release $TAG at $SHA"
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
# Attached to the 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: env.IS_RELEASE == 'true'
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="v$VERSION"
ASSET="mapping-${VERSION}.txt.gz"
gzip -c "$MAP" > "/tmp/$ASSET"
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
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"

View File

@@ -4,7 +4,11 @@ name: Translations
# only touch values-*/strings.xml) get quick feedback without the full Android
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
on:
pull_request:
push:
branches:
- '**'
tags-ignore:
- '**'
paths:
- 'app/src/main/res/values*/strings.xml'
- 'app/src/main/res/xml/locales_config.xml'

View File

@@ -236,34 +236,22 @@ pass on the existing controls; new toggles ride in with their own features.
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
default reminder **+ per-calendar override**, bundled with battery-exemption
hardening. Full sketch in "Reminders — defaults & delivery reliability" below.
10. **The `.ics` engine — export + import** *(shipped v2.7.0, 2026-06-18)* — one
10. **The `.ics` engine — export + import** *(in progress → v2.7)* — one
hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`),
four surfaces all shipped: single-event share + whole-calendar backup
(export), open-`.ics`→form + whole-calendar bulk import (import). Closed the
device-local-calendar data-loss gap (#10/#11 merged here). Built as two
sequential branches: `feat/ics-export` (write side + UID-on-create precursor)
then `feat/ics-import` (parser, restore, dedup by UID). Import is
liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` / `RECURRENCE-ID`
/ guest lists it can't model. Plans:
`docs/superpowers/plans/2026-06-18-05-ics-export.md` + `…-06-ics-import.md`.
11. **Snooze / dismiss notification actions** *(merged into release/v2.8.0)*
followed the `.ics` work; inherits v2.6's deferred exact-alarm/WorkManager
decision (snooze must re-fire an alarm).
**Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)*
12. **Attendee editing** *(next — promoted from the gated "Locations & People"
bucket, owner says high-importance 2026-06-22)* — attendees are already
*read* (queried, mapped, shown on the detail screen since v0.6) but the
event form can't *write* them, the last big read-only gap in the event
model. Add an attendees section to `EventEditScreen` / `EventForm`: add by
typed email (contact-picker entry can follow later), edit/remove rows, set
role (required / optional) — writing `CalendarContract.Attendees` rows on
insert + dirty-checked update, mirroring the reminders-diff pattern.
**Caveat to resolve first:** writing `Attendees` rows triggers sync-adapter
*invitation* behavior that differs by backend (Google auto-emails invites;
DAVx5/CalDAV writes the `ATTENDEE` property and lets the server decide) —
decide and document what Calendula promises before building. Full sketch in
"Attendee editing" under Locations & People below.
four surfaces: single-event share + whole-calendar backup (export),
open-`.ics`→form + whole-calendar restore (import). Closes the
device-local-calendar data-loss gap (#10/#11 merged here). Built as **two
sequential branches in one release**: `feat/ics-export` (write side +
UID-on-create precursor) then `feat/ics-import` (parser, restore, dedup).
Import is liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` /
`RECURRENCE-ID` it can't model. Timezone rule: all-day `VALUE=DATE`,
non-recurring timed UTC `Z`, recurring timed `TZID`-labelled from the stored
`EVENT_TIMEZONE` (no `VTIMEZONE` blocks; resolved against the OS tz DB on
import). Plan: `docs/superpowers/plans/2026-06-18-05-ics-export.md`.
11. **Snooze / dismiss notification actions** *(next, after v2.7)* — follows the
`.ics` work; inherits v2.6's deferred exact-alarm/WorkManager decision (snooze
must re-fire an alarm).
12. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog)
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
@@ -275,13 +263,11 @@ pass on the existing controls; new toggles ride in with their own features.
2026-06-17; cheap but low value, pick up only if asked
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
full-text search. Pulled in opportunistically, not sequenced.
full-text search, ICS file import. Pulled in opportunistically, not sequenced.
Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics`
export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected).
**Next committed work is Tier 5 #12 — attendee editing** (the last read-only gap
in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
(quick-settings tile, now-line, week numbers in month, full-text search).
Debatable calls worth a second look: whether **local-calendar backup (#10)**
should lead Tier 4 outright (it's a silent data-loss risk, not a feature);
whether drag-drop (#12) jumps ahead given its daily-driver impact.
## Navigation & views
@@ -302,6 +288,8 @@ in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
## Event editing & creation
- Drag & drop rescheduling in day/week (recurring drops reuse the scope
dialog) — big-ticket, own slice
- Duplicate event (detail action → prefilled create form)
- **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form)
*(next)* — chosen to follow the in-progress tap-to-create + calendar
@@ -331,11 +319,13 @@ in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
go/no-go gate as the OSM/INTERNET item below.
- Move event to another calendar (copy+delete model with a consequences
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
- ~~**Local-calendar backup / export** *(Tier 4 #10)*~~ **shipped v2.7.0**
device-only (`ACCOUNT_TYPE_LOCAL`) calendars had no sync and therefore no
backup. Settings → Calendars → Export writes every event to a user-chosen
`.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by
UID). Closed the silent data-loss gap.
- **Local-calendar backup / export** *(Tier 4 #10)* — device-only
(`ACCOUNT_TYPE_LOCAL`) calendars are first-class in Calendula but have **no
sync and therefore no backup**: a lost/wiped phone destroys them permanently.
Whole-calendar `.ics` (VCALENDAR) export to a user-chosen file (SAF), plus
restore-on-import that recreates events into a chosen local calendar. Reuses
the .ics serializer from the single-event share work; the restore path reuses
the import parser. A data-integrity obligation, not a feature.
## Reminders — defaults & delivery reliability *(implemented 2026-06-17, `feat/default-reminders` — pending on-device review)*
@@ -429,11 +419,9 @@ it directly undermines the feature's premise, so it rides in here.
## Sharing & interop
- ~~Share event as .ics + open/receive .ics into a prefilled create form~~
**shipped v2.7.0** — single-event share from detail; opening an `.ics` with one
event prefills the create form, many events opens a bulk import (dedup by UID)
- ~~ICS file import~~ **shipped v2.7.0** — covered by the open/receive `.ics`
flow above (single → form, many → bulk import)
- Share event as .ics + open/receive .ics into a prefilled create form
(front-runs the import below)
- ICS file import (drag-and-drop) *(was v3.0, optional)*
## Platform & launchers
@@ -467,57 +455,9 @@ in detail yet:
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** *(promoted out of this gated bucket 2026-06-22 — now
Tier 5 #12, high-importance; the no-permission typed-email path is not an
INTERNET/contacts call)*. See the "Attendee editing" sketch below.
### Attendee editing *(Tier 5 #12, opened 2026-06-22)*
The last read-only gap in the event model: attendees are read & shown on the
detail screen (since v0.6) but the form can't write them. Make guests editable.
- **Read side already done:** `Attendee` domain model + status/relationship/type
enums, `queryAttendees` + `EventDetailMapper.toAttendee` (with tests), and the
attendees `DetailCard` + `AttendeeRow` in `EventDetailScreen`. Nothing to add
there.
- **Write side — SHIPPED (`feat/attendee-editing`, commit b0f34ff, 2026-06-22):**
`attendees` on `EventForm`; a Guests section in `EventEditScreen` rendered as an
inline grouped list — each guest a tonal card (avatar, name/email, tappable
Required/Optional role chip, remove), the trailing card an inline email field
(type → Done commits). Persisted by `reconcileAttendees` diffing the provider's
`CalendarContract.Attendees` rows on insert + dirty-checked update (new guests =
`RELATIONSHIP_ATTENDEE` / `STATUS_INVITED`; kept rows keep their status, only the
required/optional type updates; organizer/resource/no-email rows untouched).
Reminders restyled to the same grouped-list pattern in passing. Needs only the
existing `WRITE_CALENDAR` — no new permission.
- **Name on manual add — SHIPPED via a contact picker** (2026-06-22). The inline
field is email-only (the quick path); the add row also has a **"from contacts"
button**: `ACTION_PICK` on `ContactsContract.CommonDataKinds.Email.CONTENT_URI`
the system Contacts app returns a URI to the picked email row, queried for
`Email.ADDRESS` **and** the contact `DISPLAY_NAME`, so a picked guest gets both
email and name in one tap. The result Intent grants temporary read access, so
**no `READ_CONTACTS` permission** — one-shot and user-driven, same no-permission
mechanism as the location address picker above. Self/organizer rows stay
non-editable.
- **Invitation behavior — DECIDED 2026-06-22: record-only, all writable
calendars.** Calendula has no INTERNET and never sends an invitation itself; it
only writes `Attendees` rows. Whether a guest is notified is decided downstream:
local calendars notify no one (no sync); CalDAV/DAVx5 PUTs the `ATTENDEE` lines
and the *server* decides iMIP delivery; Google's sync adapter pushes the change
and Google decides (third-party attendee writes are historically unreliable
there). Editing is allowed on **any writable calendar** — not gated to local.
- **Honest, backend-aware copy is mandatory** (this is the whole point of the
decision): on a synced calendar show "Calendula doesn't send invitations —
your calendar account may email guests when it syncs"; on a local calendar
show "Stored on this device. No one is notified."
- Calendula must **not fabricate an ORGANIZER** or otherwise fake scheduling
state to coax a send — it writes the guest list faithfully and leaves
scheduling entirely to the backend.
- The optional "send an .ics invite via your email app" delegate (`ACTION_SEND`,
still no INTERNET) was considered and **deferred** — revisit only if users ask
to notify guests explicitly.
- **Out of scope (for now):** RSVP/your-own-response editing, free/busy lookups,
resource booking — all carry server round-trips or richer sync semantics.
- **Attendee editing / invites from contacts** — own milestone; writing
`Attendees` rows touches sync-adapter invitation behavior (Google vs
DAVx5 differ).
## Consciously rejected
@@ -525,5 +465,3 @@ detail screen (since v0.6) but the form can't write them. Make guests editable.
- Natural-language quick entry (high effort, locale-fragile; the prefilled
form already covers fast entry)
- Quick-add sheet (the prefilled full form already covers it — cut in v2.0)
- Drag & drop rescheduling in day/week — **rejected** (owner decision,
reaffirmed 2026-06-22): not wanted. Rescheduling stays via the edit form.

View File

@@ -1,27 +1,17 @@
# Calendula — Current State
*Last updated: 2026-06-22*
*Last updated: 2026-06-17*
## Status
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11.
**Phase:** post-2.x theme-based backlog work (organised in `ROADMAP.md`).
**Latest released tag: v2.7.5.** The whole Tier 4 (reliability/data-safety/
interop) arc is now done or in flight:
- v2.4.0 per-event colors (2026-06-17)
- v2.5.0 jump-to-date, Agenda view, agenda + month home-screen widgets, "New
event" launcher shortcut (2026-06-17)
- v2.6.0 default reminders (global + per-calendar override, all-day default,
battery-exemption row) + system per-app language (2026-06-18)
- v2.7.0 **`.ics` engine** — single-event share, local-calendar backup export,
open/receive `.ics` (single → form, many → bulk import, dedup by UID)
(2026-06-18)
- v2.7.1v2.7.5 — crash-reporting + F-Droid reproducible-build hardening + fixes
**Next release `release/v2.8.0` (integration branch, not yet cut to main):**
holds crash reports via the public Codeberg tracker (MR !27) + reminder
snooze/dismiss notification actions (MR !28). Version bump to 2.8.0 happens at
release-cut.
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11;
v2.1.0 (month event grid, drawer view tabs, cursor fix) shipped 2026-06-15.
**Phase:** post-2.1 backlog work. v2.2.0 (tap-to-create in day/week + local
calendar management) and v2.3.0 (Material 3 grouped-list redesign of Settings,
the calendar manager and the navigation drawer) both shipped 2026-06-16;
v2.4.0 (per-event colors) and v2.5.0 (jump-to-date, Agenda view, home-screen
agenda + month widgets, and a "New event" launcher shortcut) shipped
2026-06-17. The backlog is now organised by theme in `ROADMAP.md`.
## Progress
@@ -130,48 +120,11 @@ release-cut.
sync" warning on the picker and in Settings. Color writes flow through
insert / dirty-checked update / occurrence-exception; mapper + form tests.
- [x] v2.5 (shipped 2026-06-17) — Agenda view (4th top-level view),
jump-to-date drawer date picker, two home-screen widgets (scrolling
"Upcoming" agenda + month grid), and a "New event" launcher long-press
shortcut
- [x] v2.6 (shipped 2026-06-18) — default reminders: global timed default +
separate all-day default + per-calendar override (timed), applied on create
with dirty-flag handling; three pickers + override list in Settings →
Notifications; battery-optimisation exemption row (status + system deep-link,
no new permission). Plus system per-app language (Android 13+) and an
immediate-effect fix for the in-app language picker
- [x] v2.7 (shipped 2026-06-18) — the `.ics` engine: share a single event as
`.ics` from the detail screen; back up local calendars (Settings → Calendars
→ Export) to a SAF file; open/receive an `.ics` — one event prefills the
create form, many events open a bulk import into a chosen calendar (dedup by
UID, skip-and-report unrepresentable VTIMEZONE / RECURRENCE-ID / guests).
Hand-rolled serializer/parser, zero deps. Plus all-day single-day UTC fix and
a widget R8 keep-rule crash fix
- [x] v2.7.1v2.7.5 (2026-06-21) — launch crash fix (listener before grant),
user-controlled crash reporting, widget loading-spinner R8 keep rule, and
F-Droid reproducible-build cleanups for the official repo
- [~] release/v2.8.0 (not yet cut) — crash reports via the public Codeberg
tracker (MR !27) + reminder snooze/dismiss notification actions (MR !28,
snooze self-schedules an exact alarm; primary delivery stays provider-broadcast)
## Next
1. Cut **v2.8.0** from `release/v2.8.0` (bump versionName → tag via the
merge-driven pipeline) once on-device review signs off
2. **Attendee editing** — the committed next feature (Tier 5 #12, high-
importance, opened 2026-06-22). Attendees are already read & shown on the
detail screen since v0.6; the gap is the write side — make guests editable
in `EventEditScreen` / `EventForm` (add by typed email, role, remove),
persisted by diffing `CalendarContract.Attendees`. No new permission for the
typed-email path. **Invitation behavior DECIDED 2026-06-22: record-only on all
writable calendars** — Calendula never sends (no INTERNET); honest backend-aware
copy ("your account may email guests when it syncs" on synced calendars, "no one
is notified" on local). Full sketch in `ROADMAP.md` → "Attendee editing".
3. Then: the two INTERNET go/no-go calls (OSM autocomplete, remote calendar
create/edit) and Tier 2/3 leftovers (quick-settings tile, now-line, week
numbers in month, full-text search, accessibility pass). Drag-and-drop
rescheduling is **rejected**.
1. Monitor the F-Droid build/publish for the v2.4.0 tag
2. Decide the "Locations & People" and "remote calendar create/edit"
go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md`
3. **Duplicate event** and **jump-to-date** are the cheap follow-ups; then
agenda view (strategic, backs a future widget). Full ranked sequence in
`ROADMAP.md` → "Near-term sequence".

View File

@@ -5,126 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.9.0] — 2026-06-25
### Added
- Choose the view Calendula opens on. A new **Default view** setting (Settings →
Appearance) lets you pick Month, Week, Day or Agenda as the view shown each
time you start the app, instead of always opening on Week. Thanks to
@devinside for the suggestion ([#1]).
- The month widget is now interactive: tap any day to open it, or tap an event to
open its details. Previously only the month's prev/next/today controls
responded. Thanks to @devinside for spotting this ([#2]).
- Predictive back. Swiping back from a screen — an event, the editor, search or
settings — now follows your finger, shrinking the screen to preview what's
behind so you can see where Back will take you before you let go; slide it back
to cancel. The preview needs Android 14 or newer; on older versions Back works
as before.
- Respect for "Remove animations". If you've turned animations off in your
device's Accessibility settings, Calendula now honours it everywhere: motion
collapses to a quick fade and the back preview is skipped.
### Changed
- Smoother, more consistent motion throughout. Expanding sections, list updates
in search and agenda, and the onboarding screens now animate the same way
across the app, instead of some places sliding or growing while others popped
in. Switching between Month, Week, Day and Agenda now cross-fades rather than
snapping, while paging within a view keeps its slide.
### Fixed
- Home-screen widgets now back out the way you came in. A day or event opened
from the Agenda widget keeps you in the agenda context, and from the Month
widget in the month context — pressing Back returns there instead of dropping
you on the week view. More broadly, the app now keeps a real view history:
switching views and drilling into a day are retraced by Back one step at a
time, down to your default view before the app exits. Thanks to @devinside for
reporting ([#2]).
## [2.8.0] — 2026-06-23
### Added
- Find events fast. A search button in the top bar of every calendar view opens
a search box — type a couple of letters and matching events (by title,
location or description) appear, soonest first with past events below. Tap a
result to open it. Search covers your whole calendar, not just what's on
screen, and skips calendars you've hidden. A recurring event shows its next
occurrence rather than the date the series first started.
- A current-time line in the day and week views. A thin coloured line marks the
present moment across today's column, so you can see at a glance where you are
in the day. It updates every minute and only appears when today is in view.
- Add and remove event guests. The create/edit form now has a Guests section:
add people by email or pick them from your contacts, mark each as required or
optional, and remove them. Calendula never sends invitations itself (it has no
internet access) — it only records the guests; if the event lives on a synced
account, that account may email them when it syncs, and on a local calendar no
one is notified. The form tells you which applies. Picking a guest from
contacts needs no contacts permission.
- Pick a location from your contacts. A contacts button beside the location
field drops a contact's address straight into an event — handy for a meeting
at someone's home or office. Like the guest picker, it needs no contacts
permission.
- A "New event" Quick Settings tile. Add it to your quick settings to jump
straight into the new-event form from anywhere. Settings → New event has a
one-tap button to add the tile (Android 13+); on older versions you can add it
from the quick-settings editor.
- Snooze and dismiss buttons on reminder notifications. Dismiss clears the
reminder; snooze hides it and brings it back after a delay you pick in
Settings → Notifications (5 to 60 minutes, default 10). Android's calendar
system won't re-post a reminder on its own, so Calendula schedules an exact
alarm to bring a snoozed one back on time.
### Changed
- Event details now show each guest's email beneath their name, instead of only
when no name is available.
- Crash and problem reports now open on the project's public Codeberg tracker,
where anyone can register and file an issue. Nothing is sent automatically —
you still review the report and submit it yourself in the browser.
## [2.7.5] — 2026-06-21
### Changed
- Further build cleanup for the official F-Droid repository: stopped embedding
AGP's dependency-metadata block in the APK, which F-Droid's reproducible-build
scanner rejects as an extra signing block. No functional or visible changes —
the same app as 2.7.4, just without that Play-oriented metadata blob.
## [2.7.4] — 2026-06-21
### Changed
- Build cleanup that lets Calendula ship in the official F-Droid repository:
removed an unused Gradle toolchain-resolver plugin, which F-Droid's offline,
reproducible build process disallows. No functional or visible changes — this
is the same app as 2.7.3.
## [2.7.3] — 2026-06-21
### Fixed
- Home-screen widgets no longer get stuck on a loading spinner in the published
(F-Droid) release build. They render via Android's background-work system, and
release optimisation (R8) was stripping a helper class it loads by name, so the
render job never ran. Added the missing keep rule — widgets now load normally.
## [2.7.2] — 2026-06-21
### Added
- Crash reporting you control. If Calendula closes unexpectedly, it now captures
a technical report and, on the next launch, offers to send it as an issue on
the project's tracker. Nothing is uploaded automatically — the report stays on
your device until you choose to share it, it contains no personal data or
calendar content (only the app, Android and device versions plus the stack
trace), and you see the full text before sending. There's also a "Report a
problem" entry in Settings, and if the app ever fails to start repeatedly, a
minimal recovery screen still lets you send the report.
## [2.7.1] — 2026-06-21
### Fixed
- Fixed the app crashing immediately on launch whenever calendar access hadn't
been granted yet (a fresh install, or after revoking the permission). The app
set up its live calendar-change listener before the permission screen could
appear, which newer Android versions reject outright — so the app died before
you could grant access. The listener now waits for the permission and attaches
itself the moment it's granted.
## [Unreleased]
## [2.7.0] — 2026-06-18
@@ -607,6 +488,3 @@ automatically, with zero telemetry and no internet permission.
- Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner
- F-Droid metadata stubs (DE + EN short/full descriptions)
- `.planning/` project-tracking documents
[#1]: https://codeberg.org/jlmakiola/calendula/issues/1
[#2]: https://codeberg.org/jlmakiola/calendula/issues/2

View File

@@ -1,6 +1,6 @@
<div align="center">
<img src="fastlane/metadata/android/en-US/images/icon.png" width="112" alt="Calendula icon">
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/icon.png" width="112" alt="Calendula icon">
<h1>Calendula</h1>
@@ -16,11 +16,11 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
</p>
<p>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="19%" alt="Week view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="19%" alt="Month view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-edit.png" width="19%" alt="Event form">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding">
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/01-week.png" width="19%" alt="Week view">&nbsp;
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/02-month.png" width="19%" alt="Month view">&nbsp;
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">&nbsp;
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/05-edit.png" width="19%" alt="Event form">&nbsp;
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding">
</p>
</div>

View File

@@ -23,13 +23,13 @@ android {
applicationId = "de.jeanlucmakiola.calendula"
minSdk = 29
targetSdk = 36
// These committed values ARE the source of truth for a release: merging
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 20900
versionName = "2.9.0"
// The git tag is the single source of truth for released builds: at
// release time .gitea/workflows/release.yaml derives both fields from
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
// default; keep them matching the latest released tag. See docs/RELEASING.md.
versionCode = 20700
versionName = "2.7.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -47,11 +47,6 @@ android {
buildTypes {
release {
// Keep release builds reproducible for F-Droid: don't let AGP embed
// build-environment git metadata (META-INF/version-control-info.textproto),
// whose `revision`/path content varies by build machine and is the only
// thing that otherwise differs from a clean from-source rebuild.
vcsInfo { include = false }
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
@@ -66,21 +61,6 @@ android {
applicationIdSuffix = ".debug"
isMinifyEnabled = false
}
// A locally-installable twin of `release`: same R8 shrinking + obfuscation
// and resource shrinking, but debug-signed and given its own applicationId
// suffix so it installs alongside both the production app (signed with the
// real key) and the debug build. Used to smoke-test a release candidate on
// a real device before tagging — R8-only breakage and first-run/permission
// states don't surface in the unminified debug build, nor on a device that
// already holds the permission. Never published. See docs/RELEASING.md.
create("releaseTest") {
initWith(getByName("release"))
applicationIdSuffix = ".releasetest"
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true
isShrinkResources = true
matchingFallbacks += "release"
}
}
compileOptions {
@@ -92,16 +72,6 @@ android {
compose = true
}
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
// a Play-oriented blob, and F-Droid's reproducible-build scanner rejects any
// "extra signing block" — so leaving it in blocks publishing to the official
// repo. It lives in the signing block, not the zip entries, so disabling it
// doesn't change the build output (reproducibility is unaffected).
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"

View File

@@ -15,14 +15,3 @@
# Keep the generated Room database implementations fully intact.
-keep class * extends androidx.room.RoomDatabase { *; }
-dontwarn androidx.room.paging.**
# WorkManager instantiates an InputMerger reflectively (Class.newInstance) from
# the fully-qualified class name persisted in the WorkSpec, so the class must
# keep both its name and a no-arg constructor. Glance renders every widget
# through a WorkManager worker (androidx.glance.session.SessionWorker) whose
# default merger is androidx.work.OverwritingInputMerger. Under R8 full mode
# (AGP 9 default) that unused no-arg constructor was stripped, so WorkManager
# threw "OverwritingInputMerger has no zero argument constructor", the
# SessionWorker never ran, and widgets were stuck on their loading layout
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
-keep class * extends androidx.work.InputMerger { <init>(...); }

View File

@@ -13,17 +13,6 @@
-->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!--
Re-fire a snoozed reminder at an exact time (the calendar provider won't —
its alert is already fired). USE_EXACT_ALARM is auto-granted to calendar
apps on API 33+; SCHEDULE_EXACT_ALARM covers API 3132 (user-revocable,
with an inexact fallback if withheld). F-Droid-clean: no Play allowlisting.
-->
<uses-permission
android:name="android.permission.SCHEDULE_EXACT_ALARM"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
returns null and the calendar manager's per-account "manage" button can't
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
@@ -39,7 +28,6 @@
<application
android:name=".CalendulaApp"
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
@@ -80,29 +68,6 @@
android:resource="@xml/shortcuts" />
</activity>
<!-- Standalone surface for a captured crash report. MainActivity routes
here on a startup crash-loop, so it stays clear of the app's Hilt
graph and Compose content. Not exported: launched only by us. -->
<activity
android:name=".ui.crash.CrashReportActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTask" />
<!-- Quick Settings tile: a one-tap "New event" shortcut in the QS panel.
Exported with BIND_QUICK_SETTINGS_TILE so only the system QS host can
bind it; the action mirrors the launcher "New event" shortcut. -->
<service
android:name=".qs.NewEventTileService"
android:exported="true"
android:icon="@drawable/ic_qs_new_event"
android:label="@string/qs_tile_new_event_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<!-- 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. -->
@@ -117,13 +82,6 @@
</intent-filter>
</receiver>
<!-- Snooze / dismiss actions on a reminder notification, plus the snooze
re-show alarm. Not exported: only our own notification buttons and
AlarmManager PendingIntents target it. -->
<receiver
android:name=".data.reminders.ReminderActionReceiver"
android:exported="false" />
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
<receiver
android:name=".widget.agenda.AgendaWidgetReceiver"

View File

@@ -2,19 +2,10 @@ package de.jeanlucmakiola.calendula
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
/**
* Application entry point. Registered as android:name=".CalendulaApp"
* in AndroidManifest.xml. Hilt initializes its component graph here.
*/
@HiltAndroidApp
class CalendulaApp : Application() {
override fun onCreate() {
super.onCreate()
// Install first thing so startup crashes are captured too (privacy-
// respecting, on-device; the user submits the report by hand).
CrashReporter.install(this)
}
}
class CalendulaApp : Application()

View File

@@ -18,14 +18,9 @@ import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
import kotlinx.datetime.LocalDate
@@ -46,31 +41,12 @@ class MainActivity : AppCompatActivity() {
// by CalendarHost's import flow.
private var requestedImportUri by mutableStateOf<Uri?>(null)
// A captured crash report awaiting the user's decision, surfaced as a dialog
// over the calendar on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below.
private var pendingCrashReport by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// If the app keeps crashing as it starts, the main UI can't be trusted
// to come up — route to the standalone report screen instead of
// re-entering the crashing graph.
if (CrashReporter.isCrashLoop(this)) {
startActivity(
Intent(this, CrashReportActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
)
finish()
return
}
enableEdgeToEdge()
requestedDetailKey = intent.detailKeyOrNull()
requestedNav = intent.navRequestOrNull()
requestedImportUri = intent.importUriOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent {
// One activity-scoped SettingsViewModel drives both the theme here
// and the Settings screen, so a theme change applies app-wide at once.
@@ -94,32 +70,10 @@ class MainActivity : AppCompatActivity() {
requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null },
)
pendingCrashReport?.let { report ->
CrashReportDialog(
report = report,
onSend = {
submitCrashReport(this@MainActivity, report)
CrashReporter.clearReport(this@MainActivity)
pendingCrashReport = null
},
onDismiss = {
// Keep the report (Settings can still reach it); just
// stop it popping on every launch.
CrashReporter.dismissPrompt(this@MainActivity)
pendingCrashReport = null
},
)
}
}
}
}
override fun onResume() {
super.onResume()
// Reaching a running UI means startup succeeded; reset the loop trail.
CrashReporter.markHealthy(this)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
@@ -141,38 +95,18 @@ class MainActivity : AppCompatActivity() {
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
}
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
val source = sourceViewOrNull()
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
return when {
// Launcher long-press "New event" shortcut. Static shortcut intents
// can't carry typed extras, so the action alone signals create-on-today.
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
getBooleanExtra(EXTRA_CREATE, false) ->
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
// A widget event tap carries both the occurrence and its source view;
// reminders (no source) fall through to [detailKeyOrNull] instead.
source != null && eventId != -1L -> WidgetNavRequest.OpenEvent(
eventId = eventId,
beginMillis = getLongExtra(EXTRA_BEGIN_MILLIS, 0L),
endMillis = getLongExtra(EXTRA_END_MILLIS, 0L),
source = source,
)
source != null && getStringExtra(EXTRA_DATE_ISO) != null ->
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!, source)
else -> null
}
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
// Launcher long-press "New event" shortcut. Static shortcut intents
// can't carry typed extras, so the action alone signals create-on-today.
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
getBooleanExtra(EXTRA_CREATE, false) ->
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
getStringExtra(EXTRA_DATE_ISO) != null ->
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!)
else -> null
}
/** The view the launching widget represents, if any (see [EXTRA_SOURCE_VIEW]). */
private fun Intent.sourceViewOrNull(): CalendarView? =
getStringExtra(EXTRA_SOURCE_VIEW)
?.let { name -> CalendarView.entries.firstOrNull { it.name == name } }
private fun Intent.detailKeyOrNull(): LongArray? {
// A widget event tap (source present) is routed through [navRequestOrNull]
// so it can also set the base view; only sourceless reminder taps land here.
if (sourceViewOrNull() != null) return null
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
if (eventId == -1L) return null
return longArrayOf(
@@ -189,10 +123,6 @@ class MainActivity : AppCompatActivity() {
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
// The [CalendarView] (by name) of the widget a launch came from. Roots the
// in-app back stack in that view; absent for non-widget launches (reminders).
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
// Fired by the launcher long-press "New event" shortcut (res/xml/
// shortcuts.xml hardcodes this string — keep the two in sync).
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
@@ -215,35 +145,14 @@ class MainActivity : AppCompatActivity() {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/**
* Open the day view anchored on [date], drilled in over the launching
* widget's [source] view (home-screen widgets). Backing out of the day
* returns to [source], then to the default home view.
*/
fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent =
/** Open the day view anchored on [date] (home-screen widgets). */
fun openDateIntent(context: Context, date: LocalDate): Intent =
Intent(context, MainActivity::class.java).apply {
data = "calendula://date/$date".toUri()
putExtra(EXTRA_DATE_ISO, date.toString())
putExtra(EXTRA_SOURCE_VIEW, source.name)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/**
* Open one occurrence's detail from a widget event tap, rooting the back
* stack in the widget's [source] view. Same occurrence-key shape as
* [eventDetailIntent]; the [source] extra is what distinguishes a widget
* tap (sets the base view) from a reminder tap (leaves it untouched).
*/
fun openEventIntent(
context: Context,
eventId: Long,
beginMillis: Long,
endMillis: Long,
source: CalendarView,
): Intent = eventDetailIntent(context, eventId, beginMillis, endMillis).apply {
putExtra(EXTRA_SOURCE_VIEW, source.name)
}
/** Open the create-event form prefilled for [date] (home-screen widgets). */
fun openCreateIntent(context: Context, date: LocalDate): Intent =
Intent(context, MainActivity::class.java).apply {

View File

@@ -1,11 +1,9 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.Manifest
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.pm.PackageManager
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
@@ -13,11 +11,9 @@ import android.os.Handler
import android.os.Looper
import android.provider.CalendarContract
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
@@ -47,14 +43,6 @@ interface CalendarDataSource {
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
fun eventDetail(eventId: Long): EventDetail?
/**
* Master/one-off events whose title, description or location contains
* [query] (case-insensitive), across all calendars, newest first. Reads the
* Events table directly so the search is unbounded in time; exception rows
* are excluded (see [SearchProjection]). [query] is assumed non-blank.
*/
fun searchEvents(query: String): List<EventInstance>
/**
* The event-colour palette the calendar's account publishes
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
@@ -174,52 +162,14 @@ class AndroidCalendarDataSource @Inject constructor(
) : CalendarDataSource {
private val resolver: ContentResolver get() = context.contentResolver
// All access to these two collections is guarded by [observerLock] because
// listeners are registered on the main thread (repository init, via ViewModel
// creation) while [ensureObserversRegistered] runs on the IO dispatcher.
private val observerLock = Any()
private val observers = mutableMapOf<() -> Unit, ContentObserver>()
private val registeredObservers = mutableSetOf<ContentObserver>()
private fun hasCalendarPermission(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
/**
* Attach any not-yet-registered observers to the provider, but only once the
* calendar permission is held. Registering a ContentObserver on the calendar
* provider without that permission throws SecurityException on newer Android
* (it used to silently no-op), which crashed the app at launch — the repo
* registers its observer eagerly, before the permission gate. Called from the
* observed reads ([calendars]/[instances]) so the observer re-attaches the
* first time a screen queries after the permission is granted.
*/
private fun ensureObserversRegistered() {
if (!hasCalendarPermission()) return
synchronized(observerLock) {
if (registeredObservers.size == observers.size) return
observers.values.forEach(::registerObserverLocked)
}
}
private fun registerObserverLocked(obs: ContentObserver) {
if (obs in registeredObservers || !hasCalendarPermission()) return
runCatching {
resolver.registerContentObserver(CalendarContract.CONTENT_URI, true, obs)
}.onSuccess { registeredObservers += obs }
.onFailure { Log.w(TAG, "Calendar observer registration skipped", it) }
}
override fun calendars(): List<CalendarSource> {
ensureObserversRegistered()
return resolver.query(
CalendarContract.Calendars.CONTENT_URI,
CalendarProjection.COLUMNS,
null, null,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
}
override fun calendars(): List<CalendarSource> = resolver.query(
CalendarContract.Calendars.CONTENT_URI,
CalendarProjection.COLUMNS,
null, null,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
/**
* Calendar-row writes must address the provider as a sync adapter and name
@@ -254,8 +204,7 @@ class AndroidCalendarDataSource @Inject constructor(
putDescription(description)
}
val uri = resolver.insert(localCalendarsUri(), values)
// No calendar name in the message — it can reach a crash report.
?: throw WriteFailedException("create local calendar")
?: throw WriteFailedException("create local calendar '$name'")
return ContentUris.parseId(uri)
}
@@ -293,7 +242,6 @@ class AndroidCalendarDataSource @Inject constructor(
}
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
ensureObserversRegistered()
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
@@ -306,89 +254,6 @@ class AndroidCalendarDataSource @Inject constructor(
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
}
override fun searchEvents(query: String): List<EventInstance> {
ensureObserversRegistered()
val trimmed = query.trim()
if (trimmed.isEmpty()) return emptyList()
// Escape the SQL LIKE wildcards so a literal % or _ in the query matches
// itself instead of acting as a wildcard.
val escaped = trimmed
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
val like = "%$escaped%"
val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
val selection = "($match) AND " +
"${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
return resolver.query(
CalendarContract.Events.CONTENT_URI,
SearchProjection.COLUMNS,
selection,
arrayOf(like, like, like),
CalendarContract.Events.DTSTART + " DESC",
)?.use { c ->
val reader = CursorColumnReader(c)
val out = ArrayList<EventInstance>(c.count)
while (c.moveToNext()) {
val base = reader.toSearchResult() ?: continue
// A recurring master's DTSTART is the series start; show its
// nearest occurrence instead so the date is the one the user
// actually cares about (and sorting reflects it).
val recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
!reader.getString(SearchProjection.IDX_RDATE).isNullOrEmpty()
out += if (recurring) {
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
base.copy(
start = begin.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
)
} ?: base
} else {
base
}
}
out
} ?: emptyList()
}
/**
* The occurrence of [eventId] nearest to now: the soonest upcoming one
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
* within the same window behind. Null when neither exists (e.g. a series
* that only starts further out than the window) — the caller then keeps the
* series-start DTSTART. Returns (begin, end) epoch millis.
*/
private fun nearestOccurrenceMillis(eventId: Long): Pair<Long, Long>? {
val now = System.currentTimeMillis()
return occurrenceInWindow(eventId, now, now + OCCURRENCE_WINDOW_MILLIS, soonestFirst = true)
?: occurrenceInWindow(eventId, now - OCCURRENCE_WINDOW_MILLIS, now, soonestFirst = false)
}
private fun occurrenceInWindow(
eventId: Long,
beginMillis: Long,
endMillis: Long,
soonestFirst: Boolean,
): Pair<Long, Long>? {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC"
return resolver.query(
uri,
arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END),
"${CalendarContract.Instances.EVENT_ID} = ?",
arrayOf(eventId.toString()),
order,
)?.use { c ->
if (!c.moveToFirst()) null else c.getLong(0) to c.getLong(1)
}
}
override fun eventDetail(eventId: Long): EventDetail? {
val attendees = queryAttendees(eventId)
val reminders = queryReminders(eventId)
@@ -628,10 +493,6 @@ class AndroidCalendarDataSource @Inject constructor(
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
}
}
// Guests are best-effort like reminders: a row that fails to attach is
// logged, not surfaced as a failed create. Calendula never sends an
// invitation — it only writes the rows; the backend decides delivery.
insertAttendees(eventId, form.attendees)
return eventId
}
@@ -661,12 +522,6 @@ class AndroidCalendarDataSource @Inject constructor(
if (updated.reminders.toSet() != original.reminders.toSet()) {
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
}
// Same untouched-set guard for guests: only reconcile when the form's
// attendees actually changed, so unrelated edits never disturb the
// organizer/resource rows the form doesn't model.
if (updated.attendees.toSet() != original.attendees.toSet()) {
reconcileAttendees(eventId, updated.attendees)
}
}
override fun updateOccurrence(
@@ -689,7 +544,6 @@ class AndroidCalendarDataSource @Inject constructor(
// Whether the provider copied the parent's reminder rows is its
// business — reconciling against the actual rows handles both ways.
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
reconcileAttendees(exceptionId, form.attendees)
return exceptionId
}
@@ -823,135 +677,6 @@ class AndroidCalendarDataSource @Inject constructor(
}
}
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */
private fun EventAttendee.key(): String = email.trim().lowercase()
/** Distinct guests by email; blanks dropped (we key and write on the address). */
private fun List<EventAttendee>.distinctByEmail(): List<EventAttendee> {
val seen = HashSet<String>()
return filter { it.email.isNotBlank() && seen.add(it.key()) }
}
private fun attendeeValues(eventId: Long, attendee: EventAttendee): ContentValues =
ContentValues().apply {
put(CalendarContract.Attendees.EVENT_ID, eventId)
put(CalendarContract.Attendees.ATTENDEE_EMAIL, attendee.email.trim())
attendee.name.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Attendees.ATTENDEE_NAME, it) }
// A plain guest — never the organizer (we don't fabricate scheduling
// identity; the backend owns that). Invited = awaiting their reply.
put(
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
CalendarContract.Attendees.RELATIONSHIP_ATTENDEE,
)
put(CalendarContract.Attendees.ATTENDEE_TYPE, attendee.providerType())
put(
CalendarContract.Attendees.ATTENDEE_STATUS,
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED,
)
}
private fun EventAttendee.providerType(): Int = if (optional) {
CalendarContract.Attendees.TYPE_OPTIONAL
} else {
CalendarContract.Attendees.TYPE_REQUIRED
}
/** Best-effort attendee inserts (like reminders); emails are never logged. */
private fun insertAttendees(eventId: Long, attendees: List<EventAttendee>) {
attendees.distinctByEmail().forEach { attendee ->
if (resolver.insert(
CalendarContract.Attendees.CONTENT_URI,
attendeeValues(eventId, attendee),
) == null
) {
Log.w(TAG, "Failed to attach a guest to event $eventId")
}
}
}
private data class AttendeeRow(
val id: Long,
val email: String,
val relationship: Int,
val type: Int,
) {
val key: String get() = email.trim().lowercase()
}
private fun queryAttendeeRows(eventId: Long): List<AttendeeRow> = resolver.query(
CalendarContract.Attendees.CONTENT_URI,
arrayOf(
CalendarContract.Attendees._ID,
CalendarContract.Attendees.ATTENDEE_EMAIL,
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
CalendarContract.Attendees.ATTENDEE_TYPE,
),
CalendarContract.Attendees.EVENT_ID + " = ?",
arrayOf(eventId.toString()),
null,
)?.use { c ->
buildList {
while (c.moveToNext()) {
add(
AttendeeRow(
id = c.getLong(0),
email = c.getString(1).orEmpty(),
relationship = c.getInt(2),
type = c.getInt(3),
),
)
}
}
} ?: emptyList()
/**
* Make the event's *editable* guest rows match [attendees]. Only rows we
* own — has an email, not the organizer, not a resource — are reconciled;
* everything else the backend put there is left untouched. Matched rows keep
* their response status (only the required/optional type is updated if it
* changed); dropped rows are deleted; new guests inserted. Calendula sends
* nothing — the backend decides if anyone is notified on sync.
*/
private fun reconcileAttendees(eventId: Long, attendees: List<EventAttendee>) {
val target = attendees.distinctByEmail()
val targetByKey = target.associateBy { it.key() }
val editable = queryAttendeeRows(eventId).filter {
it.email.isNotBlank() &&
it.relationship != CalendarContract.Attendees.RELATIONSHIP_ORGANIZER &&
it.type != CalendarContract.Attendees.TYPE_RESOURCE
}
val editableKeys = editable.map { it.key }.toSet()
editable.forEach { row ->
val want = targetByKey[row.key]
if (want == null) {
resolver.delete(
CalendarContract.Attendees.CONTENT_URI,
CalendarContract.Attendees._ID + " = ?",
arrayOf(row.id.toString()),
)
} else if (row.type != want.providerType()) {
resolver.update(
CalendarContract.Attendees.CONTENT_URI,
ContentValues().apply {
put(CalendarContract.Attendees.ATTENDEE_TYPE, want.providerType())
},
CalendarContract.Attendees._ID + " = ?",
arrayOf(row.id.toString()),
)
}
}
target.filterNot { it.key() in editableKeys }.forEach { attendee ->
if (resolver.insert(
CalendarContract.Attendees.CONTENT_URI,
attendeeValues(eventId, attendee),
) == null
) {
Log.w(TAG, "Failed to attach a guest to event $eventId")
}
}
}
private fun Map<String, Any?>.toContentValues(): ContentValues =
ContentValues().also { cv ->
forEach { (column, value) ->
@@ -960,8 +685,7 @@ class AndroidCalendarDataSource @Inject constructor(
is String -> cv.put(column, value)
is Long -> cv.put(column, value)
is Int -> cv.put(column, value)
// Only the type, never the value — a cell value can be event content.
else -> error("Unsupported value type for column '$column': ${value::class.simpleName}")
else -> error("Unsupported value for $column: $value")
}
}
}
@@ -994,20 +718,16 @@ class AndroidCalendarDataSource @Inject constructor(
listener()
}
}
synchronized(observerLock) {
observers[listener] = obs
// Attach now if we already hold the permission; otherwise it stays
// pending and re-attaches on the first read after the grant.
registerObserverLocked(obs)
}
observers[listener] = obs
resolver.registerContentObserver(
CalendarContract.CONTENT_URI,
/* notifyForDescendants = */ true,
obs,
)
}
override fun unregisterChangeListener(listener: () -> Unit) {
synchronized(observerLock) {
observers.remove(listener)?.let { obs ->
if (registeredObservers.remove(obs)) resolver.unregisterContentObserver(obs)
}
}
observers.remove(listener)?.let { resolver.unregisterContentObserver(it) }
}
private fun queryAttendees(eventId: Long): List<Attendee> = resolver.query(
@@ -1046,12 +766,5 @@ class AndroidCalendarDataSource @Inject constructor(
* together (by account) in the filter sheet and calendar manager.
*/
const val LOCAL_ACCOUNT_NAME = "Calendula"
/**
* How far ahead/behind a search looks for a recurring event's nearest
* occurrence (~2 years). Wide enough for everyday series; a series that
* next fires beyond it falls back to its series-start date.
*/
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
}
}

View File

@@ -16,13 +16,6 @@ interface CalendarRepository {
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
suspend fun eventDetail(eventId: Long): EventDetail
/**
* Events whose title, description or location contains [query], with hidden
* calendars removed and newest first. Empty when [query] is blank. Searches
* the whole history/future (see [CalendarDataSource.searchEvents]).
*/
suspend fun searchEvents(query: String): List<EventInstance>
/**
* The event-colour palette a calendar's account publishes; empty when it
* exposes none (see [CalendarDataSource.eventColorPalette]).

View File

@@ -80,13 +80,6 @@ class CalendarRepositoryImpl @Inject constructor(
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
}
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
if (query.isBlank()) return@withContext emptyList()
val hidden = prefs.hiddenCalendarIds.first()
dataSource.searchEvents(query)
.let { if (hidden.isEmpty()) it else it.filterNot { e -> e.calendarId in hidden } }
}
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
withContext(io) { dataSource.eventColorPalette(calendarId) }

View File

@@ -139,44 +139,6 @@ internal object EventExportProjection {
const val IDX_CALENDAR_ID = 13
}
/**
* Master/one-off Events rows matched by a full-text search. Like
* [EventExportProjection] it reads the Events table directly (so the search is
* unbounded in time), carrying DURATION for recurring rows that have no DTEND.
* Colour folds the calendar fallback like [InstanceProjection].
*/
internal object SearchProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.CALENDAR_ID,
CalendarContract.Events.TITLE,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.DURATION,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.EVENT_COLOR,
CalendarContract.Events.CALENDAR_COLOR,
CalendarContract.Events.EVENT_LOCATION,
// Recurrence markers: a non-empty RRULE or RDATE means the result should
// display its nearest occurrence, not the series-start DTSTART.
CalendarContract.Events.RRULE,
CalendarContract.Events.RDATE,
)
const val IDX_ID = 0
const val IDX_CALENDAR_ID = 1
const val IDX_TITLE = 2
const val IDX_DTSTART = 3
const val IDX_DTEND = 4
const val IDX_DURATION = 5
const val IDX_ALL_DAY = 6
const val IDX_EVENT_COLOR = 7
const val IDX_CALENDAR_COLOR = 8
const val IDX_LOCATION = 9
const val IDX_RRULE = 10
const val IDX_RDATE = 11
}
internal object AttendeeProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Attendees.ATTENDEE_NAME,

View File

@@ -1,40 +0,0 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
/**
* Map an Events-table row (a search hit) to an [EventInstance]. Unlike the
* Instances query this reads the series master, so there is no instance id (the
* event id stands in as the list key) and recurring rows carry DURATION instead
* of DTEND — reconstruct the end the same way the `.ics` export does.
*/
internal fun ColumnReader.toSearchResult(): EventInstance? {
val dtStart = getLong(SearchProjection.IDX_DTSTART)
if (dtStart < 0L) return null
val end = when {
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
}.coerceAtLeast(dtStart)
val rawTitle = getString(SearchProjection.IDX_TITLE)
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
getInt(SearchProjection.IDX_CALENDAR_COLOR)
} else {
getInt(SearchProjection.IDX_EVENT_COLOR)
}
val eventId = getLong(SearchProjection.IDX_ID)
return EventInstance(
instanceId = eventId,
eventId = eventId,
calendarId = getLong(SearchProjection.IDX_CALENDAR_ID),
title = title,
start = dtStart.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
color = color,
location = getString(SearchProjection.IDX_LOCATION),
)
}

View File

@@ -1,188 +0,0 @@
package de.jeanlucmakiola.calendula.data.crash
import android.content.Context
import android.content.pm.PackageInfo
import android.os.Build
import androidx.core.content.pm.PackageInfoCompat
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
/**
* Privacy-respecting crash capture (prod-readiness item 10). On an uncaught
* exception it writes a self-contained report to the app's private storage and
* then chains to the platform's default handler, so the process still dies
* normally (and the OS shows its own "stopped" dialog). Nothing is uploaded —
* the app holds no `INTERNET` permission. The user submits the report later,
* by hand, as a Gitea issue (see the ui/crash surfaces).
*
* The report is built from a fixed [CrashContext] allowlist — app/Android/device
* version, locale, time, and the stack trace — and **nothing else**: no device
* identifiers, no account names, no calendar/event content, no logcat. The user
* is always shown the full text before it leaves the device.
*/
object CrashReporter {
/**
* Install the handler. Called first thing in `CalendulaApp.onCreate()` so it
* also catches crashes during startup. The handler swallows nothing — it
* persists, then delegates to the previously-registered handler.
*/
fun install(context: Context) {
val appContext = context.applicationContext
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
// Capturing must never mask the original crash, so guard every step.
runCatching {
val now = System.currentTimeMillis()
writeReport(appContext, buildCrashReport(CrashContext.from(appContext), throwable, now))
recordCrashTime(appContext, now)
}
previous?.uncaughtException(thread, throwable)
}
}
/** The persisted report from the last crash, or null if there is none. */
fun pendingReport(context: Context): String? {
val file = reportFile(context)
return if (file.exists()) runCatching { file.readText() }.getOrNull()?.takeIf { it.isNotBlank() } else null
}
/**
* Whether to surface the report unprompted (on the next launch): a report
* exists and the user hasn't already waved this one away. Settings reaches
* the report via [pendingReport] regardless, so "Not now" only stops the
* auto-prompt — it doesn't discard the report.
*/
fun shouldPrompt(context: Context): Boolean =
reportFile(context).exists() && !dismissedFile(context).exists()
/** Stop auto-prompting for the current report without discarding it. */
fun dismissPrompt(context: Context) {
runCatching { dismissedFile(context).apply { parentFile?.mkdirs() }.writeText("") }
}
/** Drop the persisted report once the user has reported it (or from Settings). */
fun clearReport(context: Context) {
runCatching { reportFile(context).delete() }
runCatching { dismissedFile(context).delete() }
}
/**
* Whether the app appears to be in a startup crash-loop: at least
* [LOOP_THRESHOLD] crashes inside [LOOP_WINDOW_MS]. In that case the main UI
* can't be trusted to start, so the caller routes straight to the standalone
* report screen instead of re-entering the crashing graph.
*/
fun isCrashLoop(context: Context): Boolean {
val times = readCrashTimes(context)
if (times.size < LOOP_THRESHOLD) return false
val recent = times.sortedDescending()
return recent[0] - recent[LOOP_THRESHOLD - 1] <= LOOP_WINDOW_MS
}
/**
* Mark the app as having started successfully, resetting the loop counter so
* an ordinary single crash much later never trips loop detection. The
* pending report itself is kept — only the timing trail is cleared.
*/
fun markHealthy(context: Context) {
runCatching { timesFile(context).delete() }
}
// --- persistence -------------------------------------------------------
private fun writeReport(context: Context, report: String) {
val file = reportFile(context).apply { parentFile?.mkdirs() }
file.writeText(report.take(MAX_REPORT_CHARS))
// A fresh crash should prompt again, even if the previous one was waved away.
runCatching { dismissedFile(context).delete() }
}
private fun recordCrashTime(context: Context, nowMillis: Long) {
val kept = (readCrashTimes(context) + nowMillis).takeLast(MAX_TIMES)
timesFile(context).apply { parentFile?.mkdirs() }
.writeText(kept.joinToString("\n"))
}
private fun readCrashTimes(context: Context): List<Long> {
val file = timesFile(context)
if (!file.exists()) return emptyList()
return runCatching { file.readLines().mapNotNull { it.trim().toLongOrNull() } }.getOrDefault(emptyList())
}
private fun crashDir(context: Context) = File(context.filesDir, CRASH_DIR)
private fun reportFile(context: Context) = File(crashDir(context), REPORT_FILE)
private fun timesFile(context: Context) = File(crashDir(context), TIMES_FILE)
private fun dismissedFile(context: Context) = File(crashDir(context), DISMISSED_FILE)
private const val CRASH_DIR = "crash"
private const val REPORT_FILE = "last_crash.txt"
private const val TIMES_FILE = "crash_times.txt"
private const val DISMISSED_FILE = "dismissed"
private const val MAX_TIMES = 5
private const val MAX_REPORT_CHARS = 64 * 1024
private const val LOOP_THRESHOLD = 2
private const val LOOP_WINDOW_MS = 10_000L
}
/**
* The allowlist of non-personal facts that go into a crash report. Built from
* [Build] and the app's own [PackageInfo]; deliberately holds no identifiers.
*/
data class CrashContext(
val appVersionName: String,
val appVersionCode: Long,
val sdkInt: Int,
val androidRelease: String,
val manufacturer: String,
val model: String,
val locale: String,
) {
companion object {
fun from(context: Context): CrashContext {
val pkg = runCatching {
context.packageManager.getPackageInfo(context.packageName, 0)
}.getOrNull()
return CrashContext(
appVersionName = pkg?.versionName ?: "?",
appVersionCode = pkg?.let { PackageInfoCompat.getLongVersionCode(it) } ?: 0L,
sdkInt = Build.VERSION.SDK_INT,
androidRelease = Build.VERSION.RELEASE ?: "?",
manufacturer = Build.MANUFACTURER ?: "?",
model = Build.MODEL ?: "?",
locale = Locale.getDefault().toLanguageTag(),
)
}
}
}
/**
* Render a crash report from the [ctx] allowlist, the [throwable]'s full stack
* trace, and the crash [nowMillis]. Pure (no Android, no I/O) so it is unit
* tested. The leading marker doubles as the file's sanity check in
* [CrashReporter.pendingReport].
*/
fun buildCrashReport(ctx: CrashContext, throwable: Throwable, nowMillis: Long): String {
val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
val time = runCatching {
Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()).format(TIME_FORMAT)
}.getOrDefault(nowMillis.toString())
return buildString {
appendLine("Calendula crash report")
appendLine("App version: ${ctx.appVersionName} (${ctx.appVersionCode})")
appendLine("Android: ${ctx.androidRelease} (API ${ctx.sdkInt})")
appendLine("Device: ${ctx.manufacturer} ${ctx.model}")
appendLine("Locale: ${ctx.locale}")
appendLine("Time: $time")
appendLine()
appendLine("Stack trace:")
append(trace)
}
}
private val TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

View File

@@ -24,8 +24,7 @@ class IcsExporter @Inject constructor(
fun writeDocument(uri: Uri, content: String) {
context.contentResolver.openOutputStream(uri)?.use { out ->
out.write(content.toByteArray(Charsets.UTF_8))
// Only the scheme — the full Uri can embed the user's chosen filename.
} ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})")
} ?: throw IOException("Could not open $uri for writing")
}
/**

View File

@@ -7,7 +7,6 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.datetime.DayOfWeek
@@ -70,20 +69,6 @@ class SettingsPrefs @Inject constructor(
store.edit { it[WEEK_START_KEY] = pref.name }
}
/**
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
* the historical hard-coded startup view — so existing users see no change
* until they pick another. Also the bottom of the in-app view back stack, so
* back from any other view returns here before exiting.
*/
val defaultView: Flow<CalendarView> = store.data.map { prefs ->
prefs[DEFAULT_VIEW_KEY].toEnum(CalendarView.Week)
}
suspend fun setDefaultView(view: CalendarView) {
store.edit { it[DEFAULT_VIEW_KEY] = view.name }
}
/**
* Optional event-form fields shown by default (the rest hide behind
* "more fields"). Stored comma-joined by enum name: an absent key means
@@ -192,20 +177,6 @@ class SettingsPrefs @Inject constructor(
store.edit { it[ALLDAY_REMINDER_TIME_KEY] = minutesOfDay.coerceIn(0, MINUTES_PER_DAY - 1) }
}
/**
* How long the notification "Snooze" action defers a reminder, in minutes.
* Tapping Snooze cancels the notification and re-posts it after this delay
* via an exact alarm (the calendar provider won't re-fire). Default 10 min;
* clamped to at least 1.
*/
val snoozeMinutes: Flow<Int> = store.data.map { prefs ->
(prefs[SNOOZE_MINUTES_KEY] ?: DEFAULT_SNOOZE_MINUTES).coerceAtLeast(1)
}
suspend fun setSnoozeMinutes(minutes: Int) {
store.edit { it[SNOOZE_MINUTES_KEY] = minutes.coerceAtLeast(1) }
}
/**
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
* keyed by calendar id. A calendar **present** in the map overrides the global
@@ -259,7 +230,6 @@ class SettingsPrefs @Inject constructor(
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
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")
@@ -273,9 +243,6 @@ class SettingsPrefs @Inject constructor(
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
private const val MINUTES_PER_DAY = 1_440
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
/** Default snooze delay for the notification "Snooze" action. */
const val DEFAULT_SNOOZE_MINUTES = 10
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
stringPreferencesKey("per_calendar_reminder_override")
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =

View File

@@ -1,124 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
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
/**
* Handles the "Snooze" and "Dismiss" actions on a reminder notification, plus
* the internal re-show when a snooze elapses. All three are app-internal
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
* alarm), so the receiver is not exported.
*
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
* already fired, so nothing re-posts it.
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
* it after the user's snooze delay.
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
* or dismiss it again.
*/
@AndroidEntryPoint
class ReminderActionReceiver : BroadcastReceiver() {
@Inject lateinit var notifier: ReminderNotifier
@Inject lateinit var scheduler: ReminderSnoozeScheduler
@Inject lateinit var settingsPrefs: SettingsPrefs
override fun onReceive(context: Context, intent: Intent) {
val alert = alertFrom(intent) ?: return
when (intent.action) {
ACTION_DISMISS -> notifier.cancel(alert)
ACTION_SNOOZE -> {
// Cancel now so the notification doesn't linger until the alarm;
// the snooze delay read is the only async work.
notifier.cancel(alert)
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
val minutes = settingsPrefs.snoozeMinutes.first()
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
scheduler.schedule(alert, triggerAt)
} finally {
pendingResult.finish()
}
}
}
ACTION_SHOW -> {
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
notifier.post(alert)
}
} finally {
pendingResult.finish()
}
}
}
}
}
companion object {
const val ACTION_SNOOZE = "de.jeanlucmakiola.calendula.reminders.SNOOZE"
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
private const val EXTRA_ALERT_ID = "alert_id"
private const val EXTRA_EVENT_ID = "event_id"
private const val EXTRA_BEGIN = "begin"
private const val EXTRA_END = "end"
private const val EXTRA_TITLE = "title"
private const val EXTRA_LOCATION = "location"
private const val EXTRA_ALL_DAY = "all_day"
/** An explicit intent to this receiver carrying [alert] as extras. */
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
Intent(context, ReminderActionReceiver::class.java).apply {
this.action = action
putExtra(EXTRA_ALERT_ID, alert.alertId)
putExtra(EXTRA_EVENT_ID, alert.eventId)
putExtra(EXTRA_BEGIN, alert.beginMillis)
putExtra(EXTRA_END, alert.endMillis)
putExtra(EXTRA_TITLE, alert.title)
putExtra(EXTRA_LOCATION, alert.location)
putExtra(EXTRA_ALL_DAY, alert.isAllDay)
}
/**
* A stable request code per (alert, action) so the three PendingIntents
* of one notification stay distinct and don't clobber each other.
*/
fun requestCode(alert: ReminderAlert, action: String): Int {
val actionOffset = when (action) {
ACTION_SNOOZE -> 1
ACTION_DISMISS -> 2
ACTION_SHOW -> 3
else -> 0
}
return alert.alertId.toInt() * 8 + actionOffset
}
private fun alertFrom(intent: Intent): ReminderAlert? {
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
return ReminderAlert(
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
endMillis = intent.getLongExtra(EXTRA_END, 0L),
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
location = intent.getStringExtra(EXTRA_LOCATION),
isAllDay = intent.getBooleanExtra(EXTRA_ALL_DAY, false),
)
}
}
}

View File

@@ -61,16 +61,6 @@ class ReminderNotifier @Inject constructor(
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setContentIntent(detailIntent(alert))
.addAction(
R.drawable.ic_notification_snooze,
context.getString(R.string.reminder_action_snooze),
actionIntent(alert, ReminderActionReceiver.ACTION_SNOOZE),
)
.addAction(
R.drawable.ic_notification_dismiss,
context.getString(R.string.reminder_action_dismiss),
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
)
.build()
try {
NotificationManagerCompat.from(context)
@@ -81,19 +71,6 @@ class ReminderNotifier @Inject constructor(
}
}
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
fun cancel(alert: ReminderAlert) {
NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID)
}
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
PendingIntent.getBroadcast(
context,
ReminderActionReceiver.requestCode(alert, action),
ReminderActionReceiver.intent(context, action, alert),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
context,
/* requestCode = */ alert.alertId.toInt(),

View File

@@ -1,55 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
*
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
* broadcast (the Etar model), but a snoozed reminder has no provider backing —
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
* back to an inexact allow-while-idle alarm only if the OS withholds the
* exact-alarm capability (API 3132 where the user revoked it).
*/
@Singleton
class ReminderSnoozeScheduler @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun schedule(alert: ReminderAlert, triggerAtMillis: Long) {
val alarmManager = context.getSystemService<AlarmManager>() ?: return
val pendingIntent = PendingIntent.getBroadcast(
context,
ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_SHOW),
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
if (canScheduleExact(alarmManager)) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} else {
// Exact alarms revoked (API 3132): an inexact wake is the honest
// best we can do without nagging for SCHEDULE_EXACT_ALARM.
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
}
}
/**
* True on API < 31 (no restriction), and on 31+ when the exact-alarm
* capability is held — auto-granted via `USE_EXACT_ALARM` on API 33+
* (Calendula is a calendar app), user-revocable on 3132.
*/
private fun canScheduleExact(alarmManager: AlarmManager): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
}

View File

@@ -41,27 +41,6 @@ data class EventForm(
*/
val colorKey: String? = null,
val color: Int? = null,
/**
* Guests the user has added/kept on the event. Calendula only writes these
* `Attendees` rows — it has no INTERNET and never sends an invitation
* itself; whether a guest is notified is decided downstream by the
* calendar's backend (local: no one; CalDAV/Google: the server/account).
* Read-only rows the form doesn't model (the organizer, resources) are
* preserved by the data layer, not carried here.
*/
val attendees: List<EventAttendee> = emptyList(),
)
/**
* One editable guest: the user controls the [email] (the identity we dedup and
* write on), an optional display [name], and whether they're [optional] rather
* than required. Response status and the organizer/resource distinction are not
* user-editable, so they aren't modelled here.
*/
data class EventAttendee(
val email: String,
val name: String = "",
val optional: Boolean = false,
)
/**
@@ -76,7 +55,6 @@ enum class EventFormField {
Availability,
Visibility,
Color,
Attendees,
}
enum class EventFormProblem {
@@ -130,24 +108,6 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
// calendar's colour.
colorKey = eventColorKey,
color = eventColor,
// Only editable guests ride in the form: drop the organizer and
// resource rows (not user-editable) and any without an email (we key
// edits and dedup on the address). The data layer preserves the rows we
// don't carry here, so they're never clobbered on save.
attendees = attendees
.filter {
it.relationship != AttendeeRelationship.Organizer &&
it.type != AttendeeType.Resource
}
.mapNotNull { a ->
a.email?.takeIf { it.isNotBlank() }?.let { email ->
EventAttendee(
email = email,
name = a.name,
optional = a.type == AttendeeType.Optional,
)
}
},
)
}
@@ -157,10 +117,9 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
* 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. Guests are covered (the form writes editable attendees), so an
* external attendee change now also trips the conflict check. Still not
* covered: status, the user's own response, reminder methods, the
* organizer/resource rows, and a recurring event's duration.
* 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,
@@ -189,7 +148,6 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (availability != Availability.Busy) add(EventFormField.Availability)
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
if (colorKey != null || color != null) add(EventFormField.Color)
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
}
fun EventForm.problems(): Set<EventFormProblem> = buildSet {

View File

@@ -1,43 +0,0 @@
package de.jeanlucmakiola.calendula.qs
import android.app.PendingIntent
import android.os.Build
import android.service.quicksettings.TileService
import de.jeanlucmakiola.calendula.MainActivity
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* Quick Settings tile: tapping it opens the create-event form on today — the
* same action as the launcher "New event" shortcut and the agenda widget's "+".
* A stateless action tile, so there is no on/off state to keep in sync.
*/
class NewEventTileService : TileService() {
// The pre-34 branch intentionally uses the deprecated Intent overload: it is
// the only form available below UpsideDownCake, and is reached only there.
@Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
override fun onClick() {
super.onClick()
val today = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date
val intent = MainActivity.openCreateIntent(this, today)
// Launch only once the device is unlocked: creating an event behind the
// keyguard makes no sense, and the shade can't start an activity over a
// locked screen anyway.
unlockAndRun {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val pending = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
startActivityAndCollapse(pending)
} else {
startActivityAndCollapse(intent)
}
}
}
}

View File

@@ -1,7 +1,5 @@
package de.jeanlucmakiola.calendula.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -14,28 +12,20 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.calendula.ui.common.calendarFadeThrough
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.drillToDay
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.selectView
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
import de.jeanlucmakiola.calendula.ui.day.DayScreen
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
import kotlinx.datetime.LocalDate
@@ -44,24 +34,14 @@ import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* Holds the top-level view back stack (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
* pill in their top bars writes back here via [onSelectView].
*
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
* A lateral switch (pill / drawer) builds a visit history so back retraces it
* (see [selectView]); a widget launch resets the stack to its own view; a date
* tap drills the day view on top. Pressing back pops one level, and the
* base-level [BackHandler] hands off to the system to exit once only the home
* view remains. So back from a widget-opened screen returns to that widget's
* view, then home; and back through pill switches walks the views in reverse.
*
* [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. (A widget event tap instead arrives
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
* widget's view.)
* so a later recomposition can't re-open it.
*/
@Composable
fun CalendarHost(
@@ -72,24 +52,15 @@ fun CalendarHost(
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
viewModel: CalendarHostViewModel = hiltViewModel(),
) {
// Wait for the persisted default view before seeding the stack, so the app
// opens straight on the user's choice instead of flashing a placeholder and
// correcting it. Brief blank first frame, matching the onboarding gate above.
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
mutableStateOf(listOf(defaultView))
}
val view = viewStack.last()
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
val onSelectView: (CalendarView) -> Unit = { view = it }
// Tapping a day in the month grid opens the day view anchored to that date.
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
val onOpenDay: (LocalDate) -> Unit = { date ->
pendingDayIso = date.toString()
viewStack = viewStack.drillToDay()
view = CalendarView.Day
}
// The event-detail screen (S4) is a full-screen destination hoisted here so
@@ -126,12 +97,6 @@ fun CalendarHost(
var showSettings by rememberSaveable { mutableStateOf(false) }
val onOpenSettings = { showSettings = true }
// Full-text search — its own overlay, opened from each calendar screen's
// top bar. Sits below the detail/edit overlays so tapping a result reveals
// the detail on top and backing out returns to the results.
var showSearch by rememberSaveable { mutableStateOf(false) }
val onOpenSearch = { showSearch = true }
// Calendar manager (reached from Settings) — its own overlay so it slides
// over Settings and survives view switches.
var showCalendars by rememberSaveable { mutableStateOf(false) }
@@ -172,50 +137,16 @@ fun CalendarHost(
}
}
// Close every overlay that can sit over the calendar, so an externally
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
// top instead of underneath whatever the user had open.
fun dismissCoveringOverlays() {
showSettings = false
showCalendars = false
detailKey = null
editKey = null
importUri = null
importForm = null
}
// A home-screen widget launch asks to open a date (→ day view), open an
// event's detail, or start a create. Handled once and cleared, mirroring
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
// view so backing out returns there (then home), not to the default.
// A home-screen widget launch asks to open a date (→ day view) or start a
// create. Handled once and cleared, mirroring [requestedDetailKey].
LaunchedEffect(widgetNavRequest) {
when (val req = widgetNavRequest) {
is WidgetNavRequest.OpenDate -> {
// Drill the day view in over the widget's view: drop any overlay
// that would cover it, so the open doesn't land under Settings/form.
dismissCoveringOverlays()
createDateIso = null
pendingDayIso = req.dateIso
viewStack = viewBaseStack(defaultView, req.source).drillToDay()
onWidgetNavConsumed()
}
is WidgetNavRequest.OpenEvent -> {
// Root the stack in the widget's view, then open the occurrence
// detail over it (same key shape as a tapped event / reminder).
dismissCoveringOverlays()
createDateIso = null
viewStack = viewBaseStack(defaultView, req.source)
val key = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
heldKey = key
detailKey = key
view = CalendarView.Day
onWidgetNavConsumed()
}
is WidgetNavRequest.Create -> {
// External "new event" entries (QS tile / launcher shortcut /
// widget) must land on top of whatever is open — the form overlay
// sits below Settings/calendars in the Box, so without this it
// would open hidden underneath them.
dismissCoveringOverlays()
val iso = req.dateIso ?: Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
heldCreateIso = iso
@@ -230,73 +161,36 @@ fun CalendarHost(
val slideSpec = rememberCalendarSlideSpec()
// Base-level back: pop the view stack while no overlay covers it (each overlay
// owns its own BackHandler and takes precedence). Disabled at the home view,
// so back there falls through to the system and exits the app.
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
editKey != null || showSettings || showCalendars || importUri != null ||
importForm != null
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
viewStack = viewStack.dropLast(1)
}
Box(modifier = modifier.fillMaxSize()) {
// Switching between the peer views (month/week/day/agenda) is lateral
// navigation, so it fades through rather than sliding — paging *within* a
// view keeps the directional slide. AnimatedContent keyed on the view type.
val viewSwitch = calendarFadeThrough()
AnimatedContent(
targetState = view,
transitionSpec = { viewSwitch },
label = "view-switch",
) { currentView ->
when (currentView) {
CalendarView.Week -> WeekScreen(
selectedView = currentView,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
CalendarView.Day -> DayScreen(
selectedView = currentView,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso,
)
CalendarView.Month -> MonthScreen(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
CalendarView.Agenda -> AgendaScreen(
selectedView = currentView,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
}
}
// Search overlay — below detail/edit in the Box so a tapped result's
// detail screen draws on top, and closing it returns to the results.
AnimatedVisibility(
visible = showSearch,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
SearchScreen(
onBack = { showSearch = false },
when (view) {
CalendarView.Week -> WeekScreen(
selectedView = view,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
)
CalendarView.Day -> DayScreen(
selectedView = view,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso,
)
CalendarView.Month -> MonthScreen(
selectedView = view,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
)
CalendarView.Agenda -> AgendaScreen(
selectedView = view,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
)
}
@@ -399,9 +293,3 @@ fun CalendarHost(
}
}
}
/** Persists the view back stack across config change / process death by ordinal. */
private val viewStackSaver = listSaver<List<CalendarView>, Int>(
save = { stack -> stack.map(CalendarView::ordinal) },
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
)

View File

@@ -1,29 +0,0 @@
package de.jeanlucmakiola.calendula.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
/**
* Supplies [CalendarHost] with the user's default startup view (M1). Held as a
* nullable [StateFlow] so the host can wait for DataStore's first emission before
* seeding its view back stack — rendering nothing for that first frame rather
* than flashing the old hard-coded Week view and then correcting it.
*/
@HiltViewModel
class CalendarHostViewModel @Inject constructor(
prefs: SettingsPrefs,
) : ViewModel() {
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
}

View File

@@ -2,9 +2,6 @@ package de.jeanlucmakiola.calendula.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.compose.animation.Crossfade
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
@@ -23,7 +20,6 @@ import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RootScreen(
modifier: Modifier = Modifier,
@@ -55,40 +51,32 @@ fun RootScreen(
onDispose { lifecycle.removeObserver(obs) }
}
// Cross-fade the one-time onboarding gates so granting permission / finishing
// onboarding eases into the next screen instead of snapping. A fade carries no
// spatial motion, so it stays appropriate under "remove animations" too.
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted ->
if (granted) {
// 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()
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
when (done) {
true -> CalendarHost(
modifier = modifier,
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = onDetailKeyConsumed,
widgetNavRequest = widgetNavRequest,
onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,
modifier = modifier,
)
null -> {}
}
}
} else {
PermissionScreen(
onGranted = { hasPermission = true },
if (hasPermission) {
// 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,
widgetNavRequest = widgetNavRequest,
onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,
modifier = modifier,
)
null -> {}
}
} else {
PermissionScreen(
onGranted = { hasPermission = true },
modifier = modifier,
)
}
}

View File

@@ -1,28 +1,14 @@
package de.jeanlucmakiola.calendula.ui
import de.jeanlucmakiola.calendula.ui.common.CalendarView
/**
* A navigation a home-screen widget asked the app to perform when launched.
* Parsed from the launch intent in MainActivity and consumed once by
* [CalendarHost]. Every request carries the [source] view of the widget it came
* from (the agenda widget → [CalendarView.Agenda], the month widget →
* [CalendarView.Month]) so the in-app back stack roots itself in that view:
* backing out of the opened date/event returns to the widget's own view, not the
* default home. (Reminder notifications are not widgets — they keep the separate
* detail-key channel and leave the base view untouched.)
* [CalendarHost] (event taps reuse the existing reminder detail-key channel, so
* they are not modelled here).
*/
sealed interface WidgetNavRequest {
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over [source]. */
data class OpenDate(val dateIso: String, val source: CalendarView) : WidgetNavRequest
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
data class OpenEvent(
val eventId: Long,
val beginMillis: Long,
val endMillis: Long,
val source: CalendarView,
) : WidgetNavRequest
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date). */
data class OpenDate(val dateIso: String) : WidgetNavRequest
/** Open the create-event form prefilled for [dateIso] (today when null). */
data class Create(val dateIso: String?) : WidgetNavRequest

View File

@@ -19,7 +19,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.EventAvailable
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -47,7 +46,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
@@ -77,7 +75,6 @@ fun AgendaScreen(
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
viewModel: AgendaViewModel = hiltViewModel(),
@@ -122,7 +119,6 @@ fun AgendaScreen(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
@@ -191,7 +187,6 @@ private fun AgendaList(
AgendaEventRow(
event = event,
position = positionOf(index, day.events.size),
modifier = calendarAnimateItem(),
onClick = { onEventClick(event) },
)
}
@@ -223,13 +218,11 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
private fun AgendaEventRow(
event: EventInstance,
position: Position,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow(
modifier = modifier,
title = title,
summary = agendaTimeSummary(event),
position = position,
@@ -281,7 +274,6 @@ private fun AgendaTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -300,12 +292,6 @@ private fun AgendaTopBar(
}
},
actions = {
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,

View File

@@ -4,6 +4,7 @@ import android.accounts.AccountManager
import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
@@ -72,7 +73,6 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
@@ -295,9 +295,10 @@ private fun CalendarEditor(
var confirmDelete by remember { mutableStateOf(false) }
val dark = isSystemInDarkTheme()
BackHandler(onBack = onClose)
Scaffold(
modifier = Modifier
.predictiveBack(onBack = onClose)
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
topBar = {

View File

@@ -1,56 +1,14 @@
package de.jeanlucmakiola.calendula.ui.common
import android.provider.Settings
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.coroutines.cancellation.CancellationException
/**
* Whether the user has asked the system to remove animations (Settings →
* Accessibility → "Remove animations", which sets the global animator duration
* scale to 0). Compose animations do *not* honour this platform flag on their
* own, so the shared motion helpers in this file check it and fall back to a
* quick cross-fade — opacity only, no spatial movement — to respect the
* vestibular intent of the setting while keeping state changes legible.
*
* Read once at composition; the scale changes rarely and only takes full effect
* after a process restart anyway.
*/
@Composable
fun rememberReduceMotion(): Boolean {
val resolver = LocalContext.current.contentResolver
return remember(resolver) {
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
}
}
/**
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
@@ -66,160 +24,17 @@ fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.fastSpatialSpec()
/**
* The fast effects spec from the active motion scheme, for opacity (fade)
* transitions. Captured in composable scope alongside [rememberCalendarSlideSpec]
* for use in non-composable transition lambdas and as the reduced-motion fallback.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.fastEffectsSpec()
/**
* Horizontal slide for navigating between adjacent months/weeks/days.
* Horizontal slide for navigating between adjacent months/weeks.
*
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
* (e.g. "today"); a jump reuses the forward direction.
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
* @param fadeSpec effects spec for the reduced-motion fade, typically
* [rememberCalendarFadeSpec].
* @param reduceMotion when true, swap the directional slide for a plain cross-fade.
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
* (e.g. "today"); a jump reuses the forward direction.
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
*/
fun calendarSlideTransition(
slideDir: Int,
spec: FiniteAnimationSpec<IntOffset>,
fadeSpec: FiniteAnimationSpec<Float>,
reduceMotion: Boolean,
): ContentTransform {
if (reduceMotion) {
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
}
val dir = if (slideDir == 0) 1 else slideDir
return slideInHorizontally(spec) { w -> dir * w }
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
}
/**
* Cross-fade [ContentTransform] for swapping whole screens or content blocks
* where there is no meaningful spatial direction (e.g. onboarding gates). Pure
* opacity, so it doubles as its own reduced-motion form.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarFadeThrough(): ContentTransform {
val fade = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
return fadeIn(fade).togetherWith(fadeOut(fade))
}
/**
* Enter transition for a vertically-revealed section (expandable rows, inline
* fields): height grows from the top while fading in. Under reduced motion the
* height growth is dropped, leaving a quick fade.
*
* Pair with [calendarCollapseExit]. This is the promoted form of the pattern
* originally inlined in the event edit form, so every expandable surface in the
* app reveals the same way.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarExpandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) {
fade
} else {
expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
}
}
/** Exit counterpart to [calendarExpandEnter]: shrink + fade, or fade only under reduced motion. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarCollapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition {
val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) {
fade
} else {
shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
}
}
/**
* Enter transition for content revealed by an `AnimatedContent`/`AnimatedVisibility`
* (e.g. search results once a query resolves): a gentle rise + fade. Reduced
* motion keeps the fade only.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarItemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) {
fade
} else {
fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 }
}
}
/**
* Shared [LazyItemScope.animateItem] wiring so list rows fade/relocate with the
* app's motion scheme instead of Compose's default spring. Returns a bare
* [Modifier] under reduced motion so rows snap into place. Requires the list to
* supply stable item keys.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun LazyItemScope.calendarAnimateItem(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
if (reduceMotion) {
Modifier
} else {
Modifier.animateItem(
fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
)
}
/**
* Standard Android predictive-back transform for a full-screen overlay: as the
* back gesture is dragged, the surface scales toward ~90%, shifts toward the
* swiped edge and rounds its corners, previewing what's behind. Completing the
* gesture invokes [onBack]; cancelling springs it back.
*
* A drop-in replacement for a screen's own `BackHandler(onBack)` — register it
* once and apply the returned [Modifier] to that screen's root so the preview
* respects the same back semantics. Under reduced motion the visual preview is
* skipped (the back still works); on API < 34 the system delivers no progress,
* so it degrades to a plain back.
*/
@Composable
fun Modifier.predictiveBack(
onBack: () -> Unit,
enabled: Boolean = true,
reduceMotion: Boolean = rememberReduceMotion(),
): Modifier {
val progress = remember { Animatable(0f) }
var fromLeftEdge by remember { mutableStateOf(true) }
PredictiveBackHandler(enabled = enabled) { events ->
try {
events.collect { event ->
fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
}
onBack()
progress.snapTo(0f)
} catch (_: CancellationException) {
progress.animateTo(0f)
}
}
if (reduceMotion) return this
return this.graphicsLayer {
val p = progress.value
val scale = 1f - 0.1f * p
scaleX = scale
scaleY = scale
translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p
shape = RoundedCornerShape(32.dp.toPx() * p)
clip = p > 0f
}
}

View File

@@ -49,31 +49,3 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
return available[(i + 1) % available.size]
}
/**
* The top-level view back stack (bottom → top): the [default] home view always
* sits at the bottom. Pressing back pops one level until only the home view
* remains, then the system exits the app. Three operations move it:
*
* - [selectView] — a lateral switch (the pill or the drawer). It keeps a visit
* history so back retraces it: switching to a not-yet-visited view pushes it on
* top; switching to one already in the stack pops back to it (collapsing the
* loop, so the depth stays bounded by the number of distinct views). Selecting
* the home view collapses to just `[default]`.
* - [viewBaseStack] — the stack a widget launch resets to: just `[default]` when
* the widget's view is the home view, else `[default, source]`. A widget entry
* starts a fresh context rather than extending in-app history.
* - [drillToDay] — pushes the day view on top (a date tap from the month grid or
* a widget), unless the day view is already current.
*/
fun List<CalendarView>.selectView(target: CalendarView): List<CalendarView> {
val existing = indexOf(target)
return if (existing >= 0) take(existing + 1) else this + target
}
fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarView> =
if (source == default) listOf(default) else listOf(default, source)
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
fun List<CalendarView>.drillToDay(): List<CalendarView> =
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -72,11 +73,11 @@ fun CollapsingScaffold(
actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
BackHandler(onBack = onBack)
val scrollBehavior =
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
Scaffold(
modifier = modifier
.predictiveBack(onBack = onBack)
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.nestedScroll(scrollBehavior.nestedScrollConnection),

View File

@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -13,7 +12,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
@@ -38,9 +36,6 @@ fun InlineTextField(
minLines: Int = 1,
keyboardType: KeyboardType = KeyboardType.Text,
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
imeAction: ImeAction = ImeAction.Default,
/** Invoked when the IME action key (e.g. Done) is pressed. */
onImeAction: (() -> Unit)? = null,
) {
val resolvedStyle = textStyle.copy(
color = if (textStyle.color.isSpecified) {
@@ -58,11 +53,7 @@ fun InlineTextField(
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
capitalization = capitalization,
imeAction = imeAction,
),
keyboardActions = onImeAction?.let { action ->
KeyboardActions(onAny = { action() })
} ?: KeyboardActions.Default,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box {

View File

@@ -1,90 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
private val DotSize = 8.dp
private val LineThickness = 2.dp
/**
* Current wall-clock instant that updates once a minute, re-aligning to each
* minute boundary (rather than ticking a fixed 60 s) so it never drifts toward
* the middle of a minute. Drives the "now" line in the day/week grids.
*/
@Composable
private fun rememberCurrentMinute(): State<Instant> {
val instant = remember { mutableStateOf(Clock.System.now()) }
LaunchedEffect(Unit) {
while (true) {
val now = Clock.System.now()
instant.value = now
delay(60_000L - now.toEpochMilliseconds() % 60_000L)
}
}
return instant
}
/**
* A thin "current time" indicator — a leading dot plus a line — drawn across a
* day column. Positioned on the same [hourHeight] scale the event blocks use so
* it lines up with the grid, and refreshed each minute. Renders nothing unless
* the wall clock is on [date]; callers mount it only for the column showing
* today, so the per-minute tick runs on a single column.
*/
@Composable
fun NowLine(
date: LocalDate,
hourHeight: Dp,
modifier: Modifier = Modifier,
) {
val now by rememberCurrentMinute()
val local = now.toLocalDateTime(TimeZone.currentSystemDefault())
if (local.date != date) return
val minutes = local.hour * 60 + local.minute
val top = hourHeight * (minutes / 60f)
val color = MaterialTheme.colorScheme.primary
Box(
modifier = modifier
.fillMaxWidth()
.height(DotSize)
.offset(y = top - DotSize / 2),
contentAlignment = Alignment.CenterStart,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(LineThickness)
.background(color),
)
// The dot anchors the line to the gutter edge, mirroring the standard
// calendar "now" marker; drawn after the line so it sits on top.
Box(
modifier = Modifier
.size(DotSize)
.background(color, CircleShape),
)
}
}

View File

@@ -173,11 +173,7 @@ fun ReminderDefaultPicker(
},
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
AnimatedVisibility(visible = customExpanded) {
CustomReminderEditor(
amountText = amountText,
onAmountChange = { amountText = it },

View File

@@ -1,59 +0,0 @@
package de.jeanlucmakiola.calendula.ui.crash
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
/**
* A deliberately minimal, standalone surface for a captured crash report.
* `MainActivity` routes here when it detects a startup crash-loop (see
* [CrashReporter.isCrashLoop]): the main UI can't be trusted to start, so this
* screen stays clear of the app's Hilt graph, DataStore-backed theme and
* Compose content — it only reads the report file and shows the report dialog.
* Plain [CalendulaTheme] defaults (follow-system, dynamic colour) avoid touching
* anything that might be the cause of the crash.
*/
class CrashReportActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val report = CrashReporter.pendingReport(this)
if (report == null) {
finish()
return
}
enableEdgeToEdge()
setContent {
CalendulaTheme {
// Opaque backdrop so the dialog doesn't float over a bare task.
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
CrashReportDialog(
report = report,
onSend = {
submitCrashReport(this, report)
CrashReporter.clearReport(this)
finish()
},
onDismiss = {
CrashReporter.clearReport(this)
finish()
},
)
}
}
}
override fun onResume() {
super.onResume()
// Reaching this screen breaks the loop; reset the timing trail so a
// later ordinary crash isn't mistaken for a loop.
CrashReporter.markHealthy(this)
}
}

View File

@@ -1,75 +0,0 @@
package de.jeanlucmakiola.calendula.ui.crash
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/**
* Asks the user to send a captured crash report as an issue. The full report is
* shown verbatim in a scrollable panel — the user sees exactly what will leave
* the device before choosing to share it (the privacy backstop). [onSend] hands
* off to [submitCrashReport]; [onDismiss] declines.
*/
@Composable
fun CrashReportDialog(
report: String,
onSend: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.BugReport, contentDescription = null) },
title = { Text(stringResource(R.string.crash_dialog_title)) },
text = {
Column {
Text(
text = stringResource(R.string.crash_dialog_message),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(12.dp))
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = report,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.heightIn(max = 220.dp)
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
}
},
confirmButton = {
TextButton(onClick = onSend) { Text(stringResource(R.string.crash_dialog_report)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.crash_dialog_dismiss)) }
},
)
}

View File

@@ -1,61 +0,0 @@
package de.jeanlucmakiola.calendula.ui.crash
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.core.net.toUri
import de.jeanlucmakiola.calendula.R
/**
* Hand the captured crash report off to the user's chosen channel: the report
* is copied to the clipboard (the reliable path for a full stack trace) and the
* project's Gitea "new issue" page is opened with the body prefilled. Nothing is
* sent automatically — the app has no network access; the user reviews and
* submits the issue themselves.
*/
fun submitCrashReport(context: Context, report: String) {
copyReportToClipboard(context, report)
val opened = runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, buildIssueUri(context, report)))
}.isSuccess
val message = if (opened) R.string.crash_report_copied else R.string.crash_report_open_failed
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
/** Open the issue tracker's template chooser for a manual (non-crash) report. */
fun openIssueTracker(context: Context) {
val uri = context.getString(R.string.report_issue_choose_url).toUri()
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) }
}
private fun copyReportToClipboard(context: Context, report: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
val label = context.getString(R.string.crash_report_clip_label)
clipboard.setPrimaryClip(ClipData.newPlainText(label, report))
}
/**
* The Gitea `issues/new` URL with `title` and `body` prefilled. A full report
* can blow past URL-length limits, so an over-long one is left out of the link
* (with a "paste from clipboard" placeholder) — the clipboard copy is the
* source of truth in that case.
*/
private fun buildIssueUri(context: Context, report: String) =
context.getString(R.string.report_issue_url).toUri().buildUpon()
.appendQueryParameter("title", context.getString(R.string.crash_report_issue_title))
.appendQueryParameter("body", buildIssueBody(context, report))
.build()
private fun buildIssueBody(context: Context, report: String): String {
val block = if (report.length > MAX_URL_REPORT_CHARS) {
context.getString(R.string.crash_report_body_paste)
} else {
"```\n$report\n```"
}
return context.getString(R.string.crash_report_body_template, block)
}
/** Keep the prefilled body comfortably under common URL-length ceilings. */
private const val MAX_URL_REPORT_CHARS = 6_000

View File

@@ -26,7 +26,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
@@ -72,11 +71,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
@@ -109,7 +105,6 @@ fun DayScreen(
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
initialDateIso: String? = null,
@@ -193,7 +188,6 @@ fun DayScreen(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
@@ -239,8 +233,6 @@ private fun DayContent(
val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// Hoisted above the per-day AnimatedContent so the vertical scroll position
// survives day-to-day swipes. We only centre on noon once, on first entry
@@ -291,7 +283,7 @@ private fun DayContent(
DayUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
label = "day-transition",
) { s ->
when (s) {
@@ -348,7 +340,6 @@ private fun DayTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -367,12 +358,6 @@ private fun DayTopBar(
}
},
actions = {
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
@@ -511,7 +496,6 @@ private fun Timeline(
blocks = state.timed,
dark = dark,
date = state.date,
today = state.today,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
modifier = Modifier
@@ -528,7 +512,6 @@ private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
date: LocalDate,
today: LocalDate,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
@@ -573,10 +556,6 @@ private fun DayColumnCard(
.padding(horizontal = 1.dp),
)
}
// Current-time line, on top of the events, only on today's column.
if (date == today) {
NowLine(date = date, hourHeight = HOUR_HEIGHT)
}
}
}
}

View File

@@ -6,6 +6,7 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
@@ -91,7 +92,6 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.OptionCard
import de.jeanlucmakiola.calendula.ui.common.currentLocale
@@ -132,6 +132,8 @@ fun EventDetailScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
BackHandler(onBack = onBack)
val context = LocalContext.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
@@ -212,7 +214,6 @@ fun EventDetailScreen(
}
Scaffold(
modifier = Modifier.predictiveBack(onBack = onBack),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
@@ -589,22 +590,10 @@ private fun AttendeeRow(attendee: Attendee) {
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
val hasName = attendee.name.isNotBlank()
Text(
text = if (hasName) attendee.name else attendee.email.orEmpty(),
text = attendee.name.ifBlank { attendee.email.orEmpty() },
style = MaterialTheme.typography.bodyMedium,
)
// Email as supporting text — only when the name is the headline, so we
// don't repeat it on rows that already fall back to the email above.
if (hasName) {
attendee.email?.takeIf { it.isNotBlank() }?.let { email ->
Text(
text = email,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
attendeeRoleLabel(attendee)?.let { roleRes ->
Text(
text = stringResource(roleRes),

View File

@@ -1,16 +1,15 @@
package de.jeanlucmakiola.calendula.ui.edit
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.ContactsContract
import android.util.Patterns
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
@@ -31,6 +30,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Notes
@@ -38,14 +38,10 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.EventAvailable
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat
@@ -53,11 +49,12 @@ import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -85,17 +82,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
@@ -104,7 +97,6 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
@@ -115,9 +107,6 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
import de.jeanlucmakiola.calendula.domain.toRRule
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
@@ -126,8 +115,6 @@ import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.OptionCard
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.positionOf
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
@@ -199,6 +186,8 @@ fun EventEditScreen(
viewModel.reset()
onClose()
}
BackHandler(onBack = close)
// The event vanished between the detail screen and the edit tap — fall
// back to the detail screen, which shows its own failure state.
LaunchedEffect(loadFailed) {
@@ -248,7 +237,6 @@ fun EventEditScreen(
}
Scaffold(
modifier = Modifier.predictiveBack(onBack = close),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
@@ -406,6 +394,7 @@ private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime }
* spring open with the expressive spatial spec instead of popping in.
* (Initially-visible sections render without animating.)
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun OptionalFormSection(
visible: Boolean,
@@ -413,8 +402,10 @@ private fun OptionalFormSection(
) {
AnimatedVisibility(
visible = visible,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) +
fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()),
exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) +
fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()),
) {
Column(modifier = Modifier.fillMaxWidth(), content = content)
}
@@ -438,37 +429,6 @@ private fun EventEditContent(
var showColorPicker by rememberSaveable { mutableStateOf(false) }
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
// Pick a guest from contacts: a one-shot system picker returning the chosen
// email row (email + display name). No READ_CONTACTS — the result Intent
// grants temporary read access, so Calendula stays no-network/no-permission.
val context = LocalContext.current
val pickContact = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.let { uri ->
readContactEmail(context, uri)?.let { (email, name) ->
viewModel.addAttendee(email, name)
}
}
}
}
// Pick a postal address from contacts: the same one-shot picker, scoped to
// address rows, dropping the chosen contact's formatted address into the
// location field. Same no-permission guarantee as the guest picker above.
val pickContactAddress = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.let { uri ->
readContactAddress(context, uri)?.let { address ->
viewModel.setLocation(address)
}
}
}
}
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
// The accent ties the form to the detail screen's design language: the
// bar under the title takes the target calendar's colour.
@@ -600,39 +560,11 @@ private fun EventEditContent(
icon = Icons.Default.Place,
iconContentDescription = stringResource(R.string.event_detail_location),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
InlineField(
value = form.location,
onValueChange = viewModel::setLocation,
placeholder = stringResource(R.string.event_detail_location),
modifier = Modifier
.weight(1f)
.padding(vertical = 4.dp),
)
IconButton(
onClick = {
runCatching {
pickContactAddress.launch(
Intent(
Intent.ACTION_PICK,
ContactsContract.CommonDataKinds.StructuredPostal
.CONTENT_URI,
),
)
}
},
modifier = Modifier.size(40.dp),
) {
Icon(
imageVector = Icons.Default.Contacts,
contentDescription = stringResource(
R.string.event_edit_location_from_contacts,
),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
}
InlineField(
value = form.location,
onValueChange = viewModel::setLocation,
placeholder = stringResource(R.string.event_detail_location),
)
}
}
@@ -655,24 +587,33 @@ private fun EventEditContent(
OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) {
Spacer(Modifier.height(gap))
// A grouped list like guests: each reminder is its own tonal card
// with connected corners, the last row being "Add reminder".
val reminders = form.reminders
val rows = reminders.size + 1
reminders.forEachIndexed { index, minutes ->
GroupedItemCard(
position = positionOf(index, rows),
icon = Icons.Default.Notifications,
title = reminderLabel(minutes),
removeContentDescription = stringResource(R.string.event_edit_remove_reminder),
onRemove = { viewModel.removeReminder(minutes) },
)
// Reminders stack one per row (remove on the right edge); the
// add chip closes the card at the bottom. The card icon centres
// on the first row, whatever that is.
EditCard(
icon = Icons.Default.Notifications,
iconContentDescription = stringResource(R.string.event_detail_reminders),
header = {
when (val first = form.reminders.firstOrNull()) {
null -> AddReminderChip(onClick = { showReminderPicker = true })
else -> ReminderRow(
label = reminderLabel(first),
onRemove = { viewModel.removeReminder(first) },
)
}
},
) {
form.reminders.drop(1).forEach { minutes ->
ReminderRow(
label = reminderLabel(minutes),
onRemove = { viewModel.removeReminder(minutes) },
)
}
if (form.reminders.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
AddReminderChip(onClick = { showReminderPicker = true })
}
}
AddActionCard(
label = stringResource(R.string.event_edit_add_reminder),
position = positionOf(reminders.size, rows),
onClick = { showReminderPicker = true },
)
}
OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) {
@@ -835,52 +776,6 @@ private fun EventEditContent(
}
}
OptionalFormSection(visible = EventFormField.Attendees in state.visibleFields) {
Spacer(Modifier.height(gap))
// Guests are an inline grouped list: each is a tonal card with a
// role toggle + remove, and the trailing card is an inline email
// field — type an address, press Done, it commits. Below sits an
// honest note — Calendula never sends invitations, so whether a
// guest is notified is the calendar backend's call.
val guests = form.attendees
val rows = guests.size + 1
guests.forEachIndexed { index, attendee ->
GuestCard(
attendee = attendee,
position = positionOf(index, rows),
onToggleOptional = { viewModel.setAttendeeOptional(attendee, it) },
onRemove = { viewModel.removeAttendee(attendee) },
)
}
AddGuestInlineCard(
position = positionOf(guests.size, rows),
onAdd = { viewModel.addAttendee(it) },
onPickContact = {
runCatching {
pickContact.launch(
Intent(
Intent.ACTION_PICK,
ContactsContract.CommonDataKinds.Email.CONTENT_URI,
),
)
}
},
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(
if (isLocalCalendar) {
R.string.event_edit_attendees_note_local
} else {
R.string.event_edit_attendees_note_synced
},
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp),
)
}
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
Spacer(Modifier.height(20.dp))
TextButton(
@@ -945,7 +840,6 @@ private fun EventEditContent(
)
}
if (showRecurrencePicker) {
RecurrencePickerDialog(
current = form.rrule,
@@ -1368,274 +1262,45 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) {
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
}
/** Corner shape for a card at [position] in a grouped run (mirrors GroupedRow). */
private fun groupedShape(position: Position, full: Dp = 20.dp, small: Dp = 6.dp): Shape =
when (position) {
Position.Alone -> RoundedCornerShape(full)
Position.Top -> RoundedCornerShape(
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
)
Position.Middle -> RoundedCornerShape(small)
Position.Bottom -> RoundedCornerShape(
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
)
}
/** The 2dp gap that visually separates grouped cards (none after the last). */
private fun groupedGap(position: Position): Modifier = when (position) {
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
Position.Bottom, Position.Alone -> Modifier
}
/**
* One entry in a grouped form list (a guest, a reminder): a leading [icon],
* a [title] with optional [supporting] line, and a remove button — all on a
* tonal card whose corners come from its [position] in the run.
*/
/** One chosen reminder: humanised lead time, remove pinned to the right edge. */
@Composable
private fun GroupedItemCard(
position: Position,
icon: ImageVector,
title: String,
removeContentDescription: String,
onRemove: () -> Unit,
supporting: String? = null,
titleMaxLines: Int = Int.MAX_VALUE,
trailing: @Composable (() -> Unit)? = null,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = groupedShape(position),
modifier = Modifier
.fillMaxWidth()
.then(groupedGap(position)),
private fun ReminderRow(label: String, onRemove: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onRemove, modifier = Modifier.size(32.dp)) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.event_edit_remove_reminder),
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis,
)
if (supporting != null) {
Text(
text = supporting,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (trailing != null) {
Spacer(Modifier.width(8.dp))
trailing()
}
IconButton(onClick = onRemove, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = removeContentDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
}
}
/**
* One guest as a grouped-list card: avatar glyph, name/email, a tappable
* Required/Optional role chip, and remove. The email shows as the supporting
* line when a name is present, otherwise as the title.
*/
@Composable
private fun GuestCard(
attendee: EventAttendee,
position: Position,
onToggleOptional: (Boolean) -> Unit,
onRemove: () -> Unit,
) {
val hasName = attendee.name.isNotBlank()
GroupedItemCard(
position = position,
icon = Icons.Default.Person,
title = if (hasName) attendee.name else attendee.email,
supporting = attendee.email.takeIf { hasName },
titleMaxLines = 1,
removeContentDescription = stringResource(R.string.event_edit_remove_guest),
onRemove = onRemove,
trailing = {
FilterChip(
selected = attendee.optional,
onClick = { onToggleOptional(!attendee.optional) },
label = {
Text(
stringResource(
if (attendee.optional) {
R.string.event_edit_attendee_optional
} else {
R.string.event_edit_attendee_required
},
),
)
},
)
},
)
}
/** The trailing "Add …" action card of a grouped form list (opens a picker). */
@Composable
private fun AddActionCard(label: String, position: Position, onClick: () -> Unit) {
Surface(
private fun AddReminderChip(onClick: () -> Unit) {
AssistChip(
onClick = onClick,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = groupedShape(position),
modifier = Modifier
.fillMaxWidth()
.then(groupedGap(position)),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
) {
label = { Text(stringResource(R.string.event_edit_add)) },
leadingIcon = {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(16.dp))
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
},
border = null,
modifier = Modifier.fillMaxWidth(),
)
}
/**
* The trailing card of the guests list: an inline email field plus a "from
* contacts" button. Type an address and press Done (or tap +) to commit a guest;
* or pick from contacts to fill email + name at once. The field clears after,
* ready for the next. Matches the form's inline-field input style — no dialog.
*/
@Composable
private fun AddGuestInlineCard(
position: Position,
onAdd: (String) -> Unit,
onPickContact: () -> Unit,
) {
var text by rememberSaveable { mutableStateOf("") }
val valid = remember(text) { Patterns.EMAIL_ADDRESS.matcher(text.trim()).matches() }
fun commit() {
if (valid) {
onAdd(text.trim())
text = ""
}
}
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = groupedShape(position),
modifier = Modifier
.fillMaxWidth()
.then(groupedGap(position)),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(16.dp))
InlineTextField(
value = text,
onValueChange = { text = it },
placeholder = stringResource(R.string.event_edit_add_guest_hint),
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Done,
onImeAction = { commit() },
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp),
)
if (valid) {
IconButton(onClick = { commit() }, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.event_edit_add_guest),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
}
}
IconButton(onClick = onPickContact, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = Icons.Default.Contacts,
contentDescription = stringResource(R.string.event_edit_add_guest_from_contacts),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
}
}
}
/**
* Read the email + display name from a contact-pick result URI. No
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
*/
private fun readContactEmail(context: Context, uri: Uri): Pair<String, String>? =
context.contentResolver.query(
uri,
arrayOf(
ContactsContract.CommonDataKinds.Email.ADDRESS,
ContactsContract.Contacts.DISPLAY_NAME,
),
null, null, null,
)?.use { cursor ->
if (!cursor.moveToFirst()) return@use null
val email = cursor.getString(0).orEmpty().trim()
val name = cursor.getString(1).orEmpty().trim()
email.takeIf { it.isNotEmpty() }?.let { it to name }
}
/**
* Read the formatted postal address from a contact-pick result URI. No
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
* The provider's formatted address is multi-line; collapse it to one line so
* it sits cleanly in the single-line location field.
*/
private fun readContactAddress(context: Context, uri: Uri): String? =
context.contentResolver.query(
uri,
arrayOf(ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS),
null, null, null,
)?.use { cursor ->
if (!cursor.moveToFirst()) return@use null
cursor.getString(0).orEmpty()
.lines()
.map { it.trim() }
.filter { it.isNotEmpty() }
.joinToString(", ")
.takeIf { it.isNotEmpty() }
}
private fun fieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Location -> R.string.event_detail_location
EventFormField.Description -> R.string.event_detail_description
@@ -1644,7 +1309,6 @@ private fun fieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Availability -> R.string.event_edit_availability
EventFormField.Visibility -> R.string.event_edit_visibility
EventFormField.Color -> R.string.event_edit_color
EventFormField.Attendees -> R.string.event_edit_attendees
}
private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
@@ -1655,7 +1319,6 @@ private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
EventFormField.Availability -> Icons.Default.EventAvailable
EventFormField.Visibility -> Icons.Default.Lock
EventFormField.Color -> Icons.Default.Palette
EventFormField.Attendees -> Icons.Default.People
}
/**

View File

@@ -13,7 +13,6 @@ import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EditSnapshot
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
@@ -354,33 +353,6 @@ class EventEditViewModel @Inject constructor(
update { it.copy(reminders = it.reminders - minutes) }
}
/**
* Add a guest, keyed on a trimmed lower-cased email so re-adding the same
* address just refreshes it (no duplicate row). A blank email is ignored —
* the email is what we write and dedup on.
*/
fun addAttendee(email: String, name: String = "", optional: Boolean = false) {
val trimmed = email.trim()
if (trimmed.isEmpty()) return
update { form ->
val attendee = EventAttendee(email = trimmed, name = name.trim(), optional = optional)
val rest = form.attendees.filterNot { it.email.equals(trimmed, ignoreCase = true) }
form.copy(attendees = rest + attendee)
}
}
fun removeAttendee(attendee: EventAttendee) =
update { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) }
/** Flip a guest between required and optional (the per-row role toggle). */
fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = update { form ->
form.copy(
attendees = form.attendees.map { a ->
if (a.email.equals(attendee.email, ignoreCase = true)) a.copy(optional = optional) else a
},
)
}
/** Moving the start drags the end along, preserving the duration. */
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.imports
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -38,7 +39,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.OptionCard
/**
@@ -57,6 +57,7 @@ fun ImportScreen(
) {
LaunchedEffect(uri) { viewModel.load(uri) }
val state by viewModel.state.collectAsStateWithLifecycle()
BackHandler(onBack = onClose)
// A single event isn't shown here — it opens the create form for review.
LaunchedEffect(state) {
@@ -64,9 +65,7 @@ fun ImportScreen(
}
Scaffold(
modifier = Modifier
.predictiveBack(onBack = onClose)
.fillMaxSize(),
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.import_title)) },

View File

@@ -23,7 +23,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -66,8 +65,6 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
@@ -89,7 +86,6 @@ fun MonthScreen(
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
viewModel: MonthViewModel = hiltViewModel(),
@@ -165,7 +161,6 @@ fun MonthScreen(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
@@ -219,8 +214,6 @@ private fun MonthContent(
val threshold = with(density) { 6.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
val swipeModifier = Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
@@ -247,7 +240,7 @@ private fun MonthContent(
MonthUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
label = "month-transition",
) { s ->
when (s) {
@@ -268,7 +261,6 @@ private fun MonthTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -287,12 +279,6 @@ private fun MonthTopBar(
}
},
actions = {
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,

View File

@@ -1,259 +0,0 @@
package de.jeanlucmakiola.calendula.ui.search
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.SearchOff
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.positionOf
import java.time.Instant as JavaInstant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/**
* Full-text event search (top-bar entry). Type a query → matching events
* (title / location / description) across the whole calendar, newest-relevant
* first; tap a result to open its detail. A full-screen overlay hosted by
* [de.jeanlucmakiola.calendula.ui.CalendarHost].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(
onBack: () -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
viewModel: SearchViewModel = hiltViewModel(),
) {
val query by viewModel.query.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
// Each fresh open starts blank and straight into typing. The ViewModel is
// activity-scoped so it outlives the overlay; clearing on (re)enter is what
// resets a previous search. Peeking a result doesn't re-run this (the screen
// stays composed under the detail), so backing out keeps the query.
LaunchedEffect(Unit) {
viewModel.setQuery("")
focusRequester.requestFocus()
keyboard?.show()
}
Scaffold(
modifier = modifier.predictiveBack(onBack = onBack),
containerColor = MaterialTheme.colorScheme.surface,
topBar = {
TopAppBar(
title = {
InlineTextField(
value = query,
onValueChange = viewModel::setQuery,
placeholder = stringResource(R.string.search_hint),
imeAction = ImeAction.Search,
onImeAction = { keyboard?.hide() },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.search_back),
)
}
},
actions = {
if (query.isNotEmpty()) {
IconButton(onClick = { viewModel.setQuery("") }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.search_clear),
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { padding ->
// imePadding shrinks the content by the keyboard, so the centered
// idle/empty message re-centres in the space above it (and the results
// list lifts clear of the keyboard too).
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.imePadding(),
) {
when (val s = state) {
SearchUiState.Idle -> SearchMessage(
icon = null,
text = stringResource(R.string.search_idle_hint),
)
is SearchUiState.Empty -> SearchMessage(
icon = Icons.Default.SearchOff,
text = stringResource(R.string.search_empty, s.query),
)
is SearchUiState.Results -> SearchResults(
events = s.events,
onEventClick = onEventClick,
)
}
}
}
}
@Composable
private fun SearchResults(
events: List<EventInstance>,
onEventClick: (EventInstance) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp),
) {
itemsIndexed(
items = events,
key = { _, event -> event.eventId },
) { index, event ->
SearchResultRow(
event = event,
position = positionOf(index, events.size),
modifier = calendarAnimateItem(),
onClick = { onEventClick(event) },
)
}
}
}
@Composable
private fun SearchResultRow(
event: EventInstance,
position: Position,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
GroupedRow(
modifier = modifier,
title = event.title,
summary = searchSummary(event),
position = position,
minHeight = 64.dp,
leading = {
Box(
modifier = Modifier
.size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp))
.background(pastelize(event.color, dark)),
)
},
onClick = onClick,
)
}
/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
@Composable
private fun searchSummary(event: EventInstance): String {
val locale = currentLocale()
val zone = remember { ZoneId.systemDefault() }
val start = remember(event.start, zone) {
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
}
val dateText = remember(locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
}.format(start)
val timeText = if (event.isAllDay) {
stringResource(R.string.event_detail_all_day)
} else {
remember(locale) {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
}.format(start)
}
val base = "$dateText · $timeText"
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
}
@Composable
private fun SearchMessage(
icon: ImageVector?,
text: String,
) {
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(48.dp),
)
Spacer(Modifier.height(16.dp))
}
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}

View File

@@ -1,77 +0,0 @@
package de.jeanlucmakiola.calendula.ui.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlin.time.Clock
import javax.inject.Inject
/** Shortest query that triggers a search; one character matches almost everything. */
private const val MIN_QUERY_LENGTH = 2
sealed interface SearchUiState {
/** No query yet, or one too short to search — show the prompt. */
data object Idle : SearchUiState
/** A query ran but matched nothing. */
data class Empty(val query: String) : SearchUiState
/** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */
data class Results(val events: List<EventInstance>) : SearchUiState
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@HiltViewModel
class SearchViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
val state: StateFlow<SearchUiState> = _query
.debounce(250L)
.map { it.trim() }
.distinctUntilChanged()
.mapLatest { q ->
if (q.length < MIN_QUERY_LENGTH) {
SearchUiState.Idle
} else {
val results = repository.searchEvents(q)
if (results.isEmpty()) SearchUiState.Empty(q)
else SearchUiState.Results(sortNearestFirst(results))
}
}
.catch { emit(SearchUiState.Idle) }
.flowOn(io)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
fun setQuery(value: String) {
_query.value = value
}
/** Soonest upcoming (and ongoing) first, then the most recent past. */
private fun sortNearestFirst(events: List<EventInstance>): List<EventInstance> {
val now = Clock.System.now()
val (upcoming, past) = events.partition { it.end >= now }
return upcoming.sortedBy { it.start } + past.sortedByDescending { it.start }
}
}

View File

@@ -1,16 +1,12 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.Manifest
import android.app.StatusBarManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Icon
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import androidx.annotation.RequiresApi
import android.text.format.DateFormat
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -37,7 +33,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ExpandLess
@@ -67,7 +62,6 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -79,21 +73,12 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.qs.NewEventTileService
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
import de.jeanlucmakiola.calendula.ui.common.Position
@@ -211,48 +196,12 @@ private fun SettingsHub(
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
onClick = onManageCalendars,
)
LanguageRow(position = Position.Middle)
ReportProblemRow(position = Position.Bottom)
LanguageRow(position = Position.Bottom)
AppVersionText()
}
}
/**
* Opens the project's issue tracker to report a problem. If a crash report was
* captured (and not yet sent), it surfaces that report first via the same
* dialog the next-launch prompt uses; otherwise it opens the issue template
* chooser. No data leaves the device until the user submits the issue.
*/
@Composable
private fun ReportProblemRow(position: Position) {
val context = LocalContext.current
var report by remember { mutableStateOf<String?>(null) }
GroupedRow(
title = stringResource(R.string.settings_report_problem),
summary = stringResource(R.string.settings_report_problem_hint),
position = position,
leading = { CategoryIcon(Icons.Default.BugReport, ChipAccent.Neutral) },
onClick = {
val pending = CrashReporter.pendingReport(context)
if (pending != null) report = pending else openIssueTracker(context)
},
)
report?.let { pending ->
CrashReportDialog(
report = pending,
onSend = {
submitCrashReport(context, pending)
CrashReporter.clearReport(context)
report = null
},
onDismiss = { report = null },
)
}
}
@Composable
private fun LanguageRow(position: Position) {
val context = LocalContext.current
@@ -408,7 +357,6 @@ private fun AppearanceScreen(
) {
var showTheme by remember { mutableStateOf(false) }
var showWeekStart by remember { mutableStateOf(false) }
var showDefaultView by remember { mutableStateOf(false) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_appearance),
@@ -420,12 +368,6 @@ private fun AppearanceScreen(
position = Position.Top,
onClick = { showTheme = true },
)
GroupedRow(
title = stringResource(R.string.settings_default_view),
summary = stringResource(state.defaultView.labelRes),
position = Position.Middle,
onClick = { showDefaultView = true },
)
GroupedRow(
title = stringResource(R.string.settings_dynamic_color),
summary = if (state.dynamicColorAvailable) {
@@ -475,16 +417,6 @@ private fun AppearanceScreen(
onDismiss = { showWeekStart = false },
)
}
if (showDefaultView) {
OptionPicker(
title = stringResource(R.string.settings_default_view),
options = IMPLEMENTED_VIEWS,
selected = state.defaultView,
label = { stringResource(it.labelRes) },
onSelect = viewModel::setDefaultView,
onDismiss = { showDefaultView = false },
)
}
}
@Composable
@@ -540,39 +472,9 @@ private fun EventFormScreen(
)
},
)
// One-tap add of the "New event" Quick Settings tile. The system prompt
// is API 33+; on older versions the tile is still addable manually from
// the QS editor, so the row simply doesn't appear there.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Spacer(Modifier.height(24.dp))
val context = LocalContext.current
GroupedRow(
title = stringResource(R.string.settings_qs_tile),
summary = stringResource(R.string.settings_qs_tile_hint),
position = Position.Alone,
onClick = { requestAddQsTile(context) },
)
}
}
}
/**
* Ask the system to add the "New event" Quick Settings tile (API 33+). The OS
* shows its own confirmation dialog and handles the already-added case, so no
* result handling is needed here.
*/
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun requestAddQsTile(context: Context) {
val statusBar = context.getSystemService(StatusBarManager::class.java) ?: return
statusBar.requestAddTileService(
ComponentName(context, NewEventTileService::class.java),
context.getString(R.string.qs_tile_new_event_label),
Icon.createWithResource(context, R.drawable.ic_qs_new_event),
context.mainExecutor,
) { /* result code unused — the system surfaces its own feedback */ }
}
/**
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
@@ -603,7 +505,6 @@ private fun NotificationsScreen(
var showDefaultReminder by remember { mutableStateOf(false) }
var showAllDayReminder by remember { mutableStateOf(false) }
var showAllDayReminderTime by remember { mutableStateOf(false) }
var showSnooze by remember { mutableStateOf(false) }
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
@@ -676,11 +577,7 @@ private fun NotificationsScreen(
}
},
)
AnimatedVisibility(
visible = expanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
AnimatedVisibility(visible = expanded) {
Column {
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
GroupedRow(
@@ -728,27 +625,8 @@ private fun NotificationsScreen(
},
onClick = { openBatteryOptimizationSettings(context) },
)
// Snooze: how long the notification's "Snooze" action defers a reminder.
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_snooze_duration),
summary = snoozeDurationLabel(state.snoozeMinutes),
position = Position.Alone,
onClick = { showSnooze = true },
)
}
if (showSnooze) {
OptionPicker(
title = stringResource(R.string.settings_snooze_duration),
options = SNOOZE_PRESETS,
selected = state.snoozeMinutes,
label = { snoozeDurationLabel(it) },
onSelect = { viewModel.setSnoozeMinutes(it) },
onDismiss = { showSnooze = false },
)
}
if (showDefaultReminder) {
ReminderDefaultPicker(
title = stringResource(R.string.settings_default_reminder),
@@ -873,18 +751,6 @@ private fun openBatteryOptimizationSettings(context: Context) {
*/
private val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */
@Composable
private fun snoozeDurationLabel(minutes: Int): String =
if (minutes % 60 == 0) {
pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
} else {
pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
}
/** A minute-of-day formatted in the device's 12/24-hour convention (e.g. "09:00"). */
private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
val time = Calendar.getInstance().apply {
@@ -965,7 +831,6 @@ private fun formFieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Availability -> R.string.event_edit_availability
EventFormField.Visibility -> R.string.event_edit_visibility
EventFormField.Color -> R.string.event_edit_color
EventFormField.Attendees -> R.string.event_edit_attendees
}
@Composable

View File

@@ -5,7 +5,6 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.common.CalendarView
/**
* Settings screen state (M4). Persisted preferences are instant to read, so
@@ -18,8 +17,6 @@ data class SettingsUiState(
val dynamicColor: Boolean = true,
val dynamicColorAvailable: Boolean = true,
val weekStart: WeekStartPref = WeekStartPref.AUTO,
/** The calendar view the app opens on, and the home of the view back stack (M1). */
val defaultView: CalendarView = CalendarView.Week,
/** Optional event-form fields shown by default (rest behind "more fields"). */
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
/** Whether Calendula posts reminder notifications (v1.4). */
@@ -33,8 +30,6 @@ data class SettingsUiState(
val defaultAllDayReminderMinutes: Int? = null,
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
/** How long the notification "Snooze" action defers a reminder; default 10 min. */
val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES,
/**
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
* calendar present in the map overrides the global default (null value = no

View File

@@ -11,7 +11,6 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -60,11 +59,8 @@ class SettingsViewModel @Inject constructor(
prefs.defaultReminderMinutes,
prefs.defaultAllDayReminderMinutes,
prefs.allDayReminderTimeMinutes,
prefs.snoozeMinutes,
) { allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze ->
ReminderDefaults(
allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze,
)
) { allowColor, defaultReminder, allDayReminder, allDayReminderTime ->
ReminderDefaults(allowColor, defaultReminder, allDayReminder, allDayReminderTime)
},
combine(
prefs.perCalendarReminderOverride,
@@ -73,15 +69,12 @@ class SettingsViewModel @Inject constructor(
) { overrides, allDayOverrides, calendars ->
ReminderOverrides(overrides, allDayOverrides, calendars)
},
prefs.defaultView,
) { base, defaults, overrides, defaultView ->
) { base, defaults, overrides ->
base.copy(
defaultView = defaultView,
allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder,
allDayReminderTimeMinutes = defaults.allDayReminderTime,
snoozeMinutes = defaults.snoozeMinutes,
perCalendarReminderOverride = overrides.timed,
perCalendarAllDayReminderOverride = overrides.allDay,
writableCalendars = overrides.calendars,
@@ -97,7 +90,6 @@ class SettingsViewModel @Inject constructor(
val defaultReminder: Int?,
val allDayReminder: Int?,
val allDayReminderTime: Int,
val snoozeMinutes: Int,
)
private data class ReminderOverrides(
@@ -118,10 +110,6 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setWeekStart(pref) }
}
fun setDefaultView(view: CalendarView) {
viewModelScope.launch { prefs.setDefaultView(view) }
}
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
}
@@ -142,10 +130,6 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
}
fun setSnoozeMinutes(minutes: Int) {
viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
}
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
}

View File

@@ -29,7 +29,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
@@ -77,11 +76,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
@@ -117,7 +113,6 @@ fun WeekScreen(
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(),
@@ -198,7 +193,6 @@ fun WeekScreen(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
@@ -249,8 +243,6 @@ private fun WeekContent(
val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// Hoisted above the per-week AnimatedContent so the vertical scroll position
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
@@ -304,7 +296,7 @@ private fun WeekContent(
WeekUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
label = "week-transition",
) { s ->
when (s) {
@@ -359,7 +351,6 @@ private fun WeekTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -378,12 +369,6 @@ private fun WeekTopBar(
}
},
actions = {
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
@@ -625,7 +610,6 @@ private fun Timeline(
blocks = state.timedByDay[day].orEmpty(),
dark = dark,
date = day,
today = state.today,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
modifier = Modifier
@@ -644,7 +628,6 @@ private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
date: LocalDate,
today: LocalDate,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
@@ -688,10 +671,6 @@ private fun DayColumnCard(
.padding(horizontal = 1.dp),
)
}
// Current-time line, on top of the events, only on today's column.
if (date == today) {
NowLine(date = date, hourHeight = HOUR_HEIGHT)
}
}
}
}

View File

@@ -41,7 +41,6 @@ import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
@@ -186,11 +185,7 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
modifier = GlanceModifier
.fillMaxWidth()
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
),
),
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date))),
)
}
@@ -204,12 +199,11 @@ private fun EventRow(event: EventInstance, dark: Boolean) {
.padding(horizontal = 4.dp, vertical = 4.dp)
.clickable(
actionStartActivity(
MainActivity.openEventIntent(
MainActivity.eventDetailIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = CalendarView.Agenda,
),
),
),

View File

@@ -49,7 +49,6 @@ import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
@@ -210,9 +209,7 @@ private fun MonthHeader(label: String) {
resId = R.drawable.ic_widget_today,
contentDescription = context.getString(R.string.widget_today),
onClick = GlanceModifier.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
),
actionStartActivity(MainActivity.openDateIntent(context, today(systemZone()))),
),
)
HeaderIcon(
@@ -303,16 +300,8 @@ private fun WeekRow(
@Composable
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
val context = LocalContext.current
Box(
modifier = GlanceModifier
.width(colW)
.height(DAY_NUMBER_HEIGHT)
// Tap a day number to open that day, rooted in the month view so back
// returns to the month grid.
.clickable(
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)),
),
modifier = GlanceModifier.width(colW).height(DAY_NUMBER_HEIGHT),
contentAlignment = Alignment.Center,
) {
Box(
@@ -364,24 +353,7 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
@Composable
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
val context = LocalContext.current
Box(
modifier = GlanceModifier
.width(width)
.height(LANE_HEIGHT)
.padding(horizontal = 1.dp)
// Tap an event bar to open its detail, rooted in the month view.
.clickable(
actionStartActivity(
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = CalendarView.Month,
),
),
),
) {
Box(modifier = GlanceModifier.width(width).height(LANE_HEIGHT).padding(horizontal = 1.dp)) {
Box(
modifier = GlanceModifier
.fillMaxSize()

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Monochrome notification-action mark: Material "close" 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,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
</vector>

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Monochrome notification-action mark: Material "snooze" 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="M7.88,3.39L6.6,1.86 2,5.71l1.29,1.53 4.59,-3.85zM22,5.72l-4.6,-3.86 -1.29,1.53 4.6,3.86L22,5.72zM12,4c-4.97,0 -9,4.03 -9,9s4.02,9 9,9c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,20c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7zM9,11h3.63L9,15.2L9,17h6v-2h-3.63L15,10.8L15,9L9,9z" />
</vector>

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Quick Settings tile icon ("New event"): a flat calendar+plus glyph. QS tile
icons are tinted by the system from their alpha, so this is a bare white
glyph on a transparent background (no brand circle, unlike the launcher
shortcut icon). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<path
android:fillColor="#FFFFFFFF"
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,19H5V8h14V19z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M11.5,10.5h1v2h2v1h-2v2h-1v-2h-2v-1h2z" />
</vector>

View File

@@ -77,16 +77,6 @@
<string name="event_edit_add">Hinzufügen</string>
<string name="event_edit_add_reminder">Erinnerung hinzufügen</string>
<string name="event_edit_remove_reminder">Erinnerung entfernen</string>
<string name="event_edit_attendees">Gäste</string>
<string name="event_edit_add_guest">Gast hinzufügen</string>
<string name="event_edit_add_guest_hint">Gast per E-Mail hinzufügen…</string>
<string name="event_edit_add_guest_from_contacts">Aus Kontakten hinzufügen</string>
<string name="event_edit_location_from_contacts">Adresse aus Kontakten wählen</string>
<string name="event_edit_remove_guest">Gast entfernen</string>
<string name="event_edit_attendee_required">Erforderlich</string>
<string name="event_edit_attendee_optional">Optional</string>
<string name="event_edit_attendees_note_synced">Calendula versendet keine Einladungen. Dein Kalenderkonto sendet Gästen bei der Synchronisierung unter Umständen eine E-Mail.</string>
<string name="event_edit_attendees_note_local">Auf diesem Gerät gespeichert. Niemand wird benachrichtigt.</string>
<string name="event_edit_reminder_custom">Benutzerdefiniert</string>
<string name="reminder_unit_minutes">Minuten</string>
<string name="reminder_unit_hours">Stunden</string>
@@ -187,15 +177,6 @@
<item quantity="one">%d Woche vorher</item>
<item quantity="other">%d Wochen vorher</item>
</plurals>
<!-- Reine Zeitdauern (ohne „vorher"), z. B. die Schlummerdauer. -->
<plurals name="duration_minutes">
<item quantity="one">%d Minute</item>
<item quantity="other">%d Minuten</item>
</plurals>
<plurals name="duration_hours">
<item quantity="one">%d Stunde</item>
<item quantity="other">%d Stunden</item>
</plurals>
<!-- Geteilte Event-Strings -->
<string name="event_untitled">(Ohne Titel)</string>
@@ -213,8 +194,6 @@
<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>
<string name="reminder_action_snooze">Schlummern</string>
<string name="reminder_action_dismiss">Verwerfen</string>
<!-- View-Switcher (M1) -->
<string name="view_month">Monat</string>
@@ -233,14 +212,6 @@
<string name="agenda_empty_title">Nichts geplant</string>
<string name="agenda_empty_subtitle">Anstehende Termine erscheinen hier.</string>
<!-- Terminsuche -->
<string name="search_action">Suchen</string>
<string name="search_hint">Termine suchen</string>
<string name="search_back">Zurück</string>
<string name="search_clear">Löschen</string>
<string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string>
<string name="search_empty">Keine Termine passen zu „%1$s“.</string>
<!-- Startbildschirm-Widgets -->
<string name="widget_agenda_title">Anstehend</string>
<string name="widget_agenda_label">Calendula Agenda</string>
@@ -256,11 +227,6 @@
<string name="shortcut_new_event_short">Neuer Termin</string>
<string name="shortcut_new_event_long">Neuen Termin erstellen</string>
<!-- Schnelleinstellungen-Kachel -->
<string name="qs_tile_new_event_label">Neuer Termin</string>
<string name="settings_qs_tile">Schnelleinstellungen-Kachel hinzufügen</string>
<string name="settings_qs_tile_hint">Eine Kachel „Neuer Termin“ zu den Schnelleinstellungen hinzufügen.</string>
<!-- Kalender-Filter (M3) -->
<string name="filter_title">Kalender</string>
@@ -299,7 +265,6 @@
<string name="settings_reliable_delivery">Zuverlässige Zustellung</string>
<string name="settings_reliable_delivery_hint">Android verzögert Erinnerungen womöglich, um Akku zu sparen. Nimm Calendula aus, damit sie pünktlich ankommen.</string>
<string name="settings_reliable_delivery_exempt">Von der Akku-Optimierung ausgenommen — Erinnerungen kommen pünktlich.</string>
<string name="settings_snooze_duration">Schlummerdauer</string>
<string name="settings_section_calendars">Kalender</string>
<string name="settings_manage_calendars">Kalender verwalten</string>
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
@@ -317,8 +282,6 @@
<string name="settings_about_source">Quellcode</string>
<string name="settings_about_version">Version %1$s</string>
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
<string name="settings_report_problem">Problem melden</string>
<string name="settings_report_problem_hint">Absturzbericht senden oder Issue-Tracker öffnen</string>
<!-- Calendar manager -->
<string name="calendars_title">Kalender</string>
@@ -374,16 +337,4 @@
<item quantity="one">%d bereits in diesem Kalender übersprungen.</item>
<item quantity="other">%d bereits in diesem Kalender übersprungen.</item>
</plurals>
<!-- Absturzberichte: vom Nutzer selbst als Gitea-Issue einreichbar -->
<string name="crash_dialog_title">Calendula ist abgestürzt</string>
<string name="crash_dialog_message">Calendula wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>
<string name="crash_dialog_report">Melden</string>
<string name="crash_dialog_dismiss">Nicht jetzt</string>
<string name="crash_report_issue_title">Absturzbericht</string>
<string name="crash_report_clip_label">Calendula-Absturzbericht</string>
<string name="crash_report_copied">Bericht in die Zwischenablage kopiert</string>
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
<string name="crash_report_body_template">Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n</string>
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
</resources>

View File

@@ -78,16 +78,6 @@
<string name="event_edit_add">Add</string>
<string name="event_edit_add_reminder">Add reminder</string>
<string name="event_edit_remove_reminder">Remove reminder</string>
<string name="event_edit_attendees">Guests</string>
<string name="event_edit_add_guest">Add guest</string>
<string name="event_edit_add_guest_hint">Add a guest by email…</string>
<string name="event_edit_add_guest_from_contacts">Add from contacts</string>
<string name="event_edit_location_from_contacts">Pick address from contacts</string>
<string name="event_edit_remove_guest">Remove guest</string>
<string name="event_edit_attendee_required">Required</string>
<string name="event_edit_attendee_optional">Optional</string>
<string name="event_edit_attendees_note_synced">Calendula doesn\'t send invitations. Your calendar account may email guests when it syncs.</string>
<string name="event_edit_attendees_note_local">Stored on this device. No one is notified.</string>
<string name="event_edit_reminder_custom">Custom</string>
<string name="reminder_unit_minutes">minutes</string>
<string name="reminder_unit_hours">hours</string>
@@ -188,15 +178,6 @@
<item quantity="one">%d week before</item>
<item quantity="other">%d weeks before</item>
</plurals>
<!-- Plain durations (no "before"), e.g. the snooze delay. -->
<plurals name="duration_minutes">
<item quantity="one">%d minute</item>
<item quantity="other">%d minutes</item>
</plurals>
<plurals name="duration_hours">
<item quantity="one">%d hour</item>
<item quantity="other">%d hours</item>
</plurals>
<!-- Shared event strings -->
<string name="event_untitled">(No title)</string>
@@ -214,8 +195,6 @@
<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>
<string name="reminder_action_snooze">Snooze</string>
<string name="reminder_action_dismiss">Dismiss</string>
<!-- View switcher (M1) -->
<string name="view_month">Month</string>
@@ -234,14 +213,6 @@
<string name="agenda_empty_title">Nothing scheduled</string>
<string name="agenda_empty_subtitle">Upcoming events will show up here.</string>
<!-- Event search -->
<string name="search_action">Search</string>
<string name="search_hint">Search events</string>
<string name="search_back">Back</string>
<string name="search_clear">Clear</string>
<string name="search_idle_hint">Search your events by title, location or notes.</string>
<string name="search_empty">No events match “%1$s”.</string>
<!-- Home-screen widgets -->
<string name="widget_agenda_title">Upcoming</string>
<string name="widget_agenda_label">Calendula agenda</string>
@@ -264,7 +235,6 @@
<string name="settings_theme_system">System</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_default_view">Default view</string>
<string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
<string name="settings_week_start">Week starts on</string>
@@ -292,7 +262,6 @@
<string name="settings_reliable_delivery">Reliable delivery</string>
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
<string name="settings_reliable_delivery_exempt">Exempt from battery optimisation — reminders arrive on time.</string>
<string name="settings_snooze_duration">Snooze duration</string>
<string name="settings_section_calendars">Calendars</string>
<string name="settings_manage_calendars">Manage calendars</string>
<string name="settings_manage_calendars_hint">Create local calendars; manage synced ones</string>
@@ -300,7 +269,7 @@
<string name="settings_language">App language</string>
<string name="settings_language_auto">System default</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
<string name="settings_appearance_subtitle">Theme, dynamic colour, week start</string>
<string name="settings_event_form_subtitle">Default fields for new events</string>
<string name="settings_notifications_subtitle">Event reminders</string>
<string name="settings_section_about">About</string>
@@ -310,8 +279,6 @@
<string name="settings_about_source">Source</string>
<string name="settings_about_version">Version %1$s</string>
<string name="settings_about_logo_desc">Calendula app icon</string>
<string name="settings_report_problem">Report a problem</string>
<string name="settings_report_problem_hint">Send a crash report or open the issue tracker</string>
<!-- Calendar manager -->
<string name="calendars_title">Calendars</string>
@@ -371,26 +338,6 @@
<string name="shortcut_new_event_short">New event</string>
<string name="shortcut_new_event_long">Create a new event</string>
<!-- Quick Settings tile -->
<string name="qs_tile_new_event_label">New event</string>
<string name="settings_qs_tile">Add Quick Settings tile</string>
<string name="settings_qs_tile_hint">Add a “New event” tile to the Quick Settings panel.</string>
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
<!-- Crash reporting: a captured report the user can submit, by hand, as a
Gitea issue (the app sends nothing automatically). -->
<string name="crash_dialog_title">Calendula crashed</string>
<string name="crash_dialog_message">Calendula closed unexpectedly last time. You can help fix it by sending this report as an issue. It stays on your device until you choose to share it, and includes no personal data or calendar content — only the technical details below.</string>
<string name="crash_dialog_report">Report</string>
<string name="crash_dialog_dismiss">Not now</string>
<string name="crash_report_issue_title">Crash report</string>
<string name="crash_report_clip_label">Calendula crash report</string>
<string name="crash_report_copied">Report copied to your clipboard</string>
<string name="crash_report_open_failed">Couldn\'t open the issue tracker. The report is on your clipboard.</string>
<string name="crash_report_body_template">Thanks for reporting a crash in Calendula. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%1$s\n</string>
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
</resources>

View File

@@ -16,7 +16,6 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var calendarsResult: List<CalendarSource> = emptyList()
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
var searchResult: (String) -> List<EventInstance> = { _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
var exportableEventsResult: List<IcsEvent> = emptyList()
@@ -52,7 +51,6 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun calendars(): List<CalendarSource> = calendarsResult
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
instancesResult(beginMillis, endMillis)
override fun searchEvents(query: String): List<EventInstance> = searchResult(query)
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
eventColorPaletteResult(calendarId)

View File

@@ -1,56 +0,0 @@
package de.jeanlucmakiola.calendula.data.crash
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class CrashReportBuilderTest {
private val context = CrashContext(
appVersionName = "2.7.0",
appVersionCode = 20700,
sdkInt = 34,
androidRelease = "14",
manufacturer = "Google",
model = "Pixel 7",
locale = "en-DE",
)
@Test
fun `report carries the allowlisted facts and the stack trace`() {
val report = buildCrashReport(context, IllegalStateException("boom"), nowMillis = 0L)
assertThat(report).startsWith("Calendula crash report")
assertThat(report).contains("App version: 2.7.0 (20700)")
assertThat(report).contains("Android: 14 (API 34)")
assertThat(report).contains("Device: Google Pixel 7")
assertThat(report).contains("Locale: en-DE")
// The exception type + message and a frame from this test are present.
assertThat(report).contains("IllegalStateException")
assertThat(report).contains("boom")
assertThat(report).contains("CrashReportBuilderTest")
}
@Test
fun `nested causes are included`() {
val cause = NullPointerException("inner")
val report = buildCrashReport(context, RuntimeException("outer", cause), nowMillis = 0L)
assertThat(report).contains("outer")
assertThat(report).contains("Caused by")
assertThat(report).contains("inner")
}
@Test
fun `report holds only the allowlisted lines before the stack trace`() {
val report = buildCrashReport(context, Exception("x"), nowMillis = 0L)
val header = report.substringBefore("Stack trace:").trim().lines()
// No identifiers, accounts, or extra fields ever creep into the header:
// it is exactly the six allowlisted lines plus the title.
assertThat(header).hasSize(6)
assertThat(header.first()).isEqualTo("Calendula crash report")
assertThat(header.map { it.substringBefore(":") }).containsExactly(
"Calendula crash report", "App version", "Android", "Device", "Locale", "Time",
).inOrder()
}
}

View File

@@ -233,23 +233,6 @@ class SettingsPrefsTest {
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(0)
}
@Test
fun `snooze duration defaults to 10 minutes`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.snoozeMinutes.first()).isEqualTo(10)
}
@Test
fun `snooze duration round-trips and clamps to at least one minute`(
@TempDir tempDir: Path,
) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setSnoozeMinutes(30)
assertThat(prefs.snoozeMinutes.first()).isEqualTo(30)
prefs.setSnoozeMinutes(0)
assertThat(prefs.snoozeMinutes.first()).isEqualTo(1)
}
@Test
fun `explicit week-start prefs resolve regardless of locale`() {
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)

View File

@@ -211,55 +211,12 @@ class EventFormTest {
}
@Test
fun `a reminder-method change the form cannot write does not fake a conflict`() {
// The form carries reminder minutes only, not the method — so a method
// flip the form can't express must not read as an external change.
val loaded = detail(reminders = listOf(Reminder(10, ReminderMethod.Alert)))
.toEditSnapshot(0L, 3_600_000L, berlin)
val fresh = detail(reminders = listOf(Reminder(10, ReminderMethod.Email)))
.toEditSnapshot(0L, 3_600_000L, berlin)
assertThat(fresh).isEqualTo(loaded)
}
@Test
fun `an external guest change now trips the conflict check`() {
// Attendees are writable now, so they ride in the snapshot: adding a
// guest externally is a real conflict, not invisible.
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.NeedsAction)),
attendees = listOf(Attendee("Ada", "ada@example.org", AttendeeStatus.Accepted)),
).toEditSnapshot(0L, 3_600_000L, berlin)
assertThat(fresh).isNotEqualTo(loaded)
}
@Test
fun `toEditForm carries editable guests with name email and optional role`() {
val prefilled = detail(
attendees = listOf(
Attendee("Ada", "ada@example.org", AttendeeStatus.Accepted, type = AttendeeType.Required),
Attendee("", "bob@example.org", AttendeeStatus.NeedsAction, type = AttendeeType.Optional),
),
).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
assertThat(prefilled.attendees).containsExactly(
EventAttendee(email = "ada@example.org", name = "Ada", optional = false),
EventAttendee(email = "bob@example.org", name = "", optional = true),
).inOrder()
assertThat(prefilled.populatedFields()).contains(EventFormField.Attendees)
}
@Test
fun `toEditForm drops the organizer, resources and guests without an email`() {
val prefilled = detail(
attendees = listOf(
Attendee("Org", "org@example.org", AttendeeStatus.Accepted, AttendeeRelationship.Organizer),
Attendee("Room A", "room@example.org", AttendeeStatus.Accepted, type = AttendeeType.Resource),
Attendee("No address", null, AttendeeStatus.NeedsAction),
Attendee("Ada", "ada@example.org", AttendeeStatus.NeedsAction),
),
).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
assertThat(prefilled.attendees).containsExactly(
EventAttendee(email = "ada@example.org", name = "Ada", optional = false),
)
assertThat(fresh).isEqualTo(loaded)
}
@Test

View File

@@ -1,94 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The pure back-stack rules behind [CalendarHost]'s top-level navigation:
* the home view is always the bottom, a lateral move keeps the stack one deep,
* and a date tap drills the day view on top. Verifies the back sequences the
* widget-UX fix promises (e.g. agenda widget → day → back → agenda → back → home).
*/
class ViewBackStackTest {
@Test
fun `a widget whose view is the home view resets to just the home`() {
assertThat(viewBaseStack(CalendarView.Week, CalendarView.Week))
.containsExactly(CalendarView.Week)
}
@Test
fun `a widget launch resets to its view over the home`() {
assertThat(viewBaseStack(CalendarView.Week, CalendarView.Month))
.containsExactly(CalendarView.Week, CalendarView.Month)
.inOrder()
}
@Test
fun `pill switches build a visit history that back retraces`() {
// Agenda → Day → Week → Month, each a new view, stacks the full history.
val stack = listOf(CalendarView.Agenda)
.selectView(CalendarView.Day)
.selectView(CalendarView.Week)
.selectView(CalendarView.Month)
assertThat(stack).containsExactly(
CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month,
).inOrder()
// Back walks the views in reverse, not straight to the home view.
assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Week)
}
@Test
fun `selecting an already-visited view collapses the loop back to it`() {
val stack = listOf(CalendarView.Week, CalendarView.Agenda, CalendarView.Day)
.selectView(CalendarView.Agenda)
assertThat(stack).containsExactly(CalendarView.Week, CalendarView.Agenda).inOrder()
}
@Test
fun `selecting the home view collapses to just the home`() {
val stack = listOf(CalendarView.Week, CalendarView.Agenda, CalendarView.Day)
.selectView(CalendarView.Week)
assertThat(stack).containsExactly(CalendarView.Week)
}
@Test
fun `drilling pushes the day view on top`() {
assertThat(listOf(CalendarView.Week, CalendarView.Month).drillToDay())
.containsExactly(CalendarView.Week, CalendarView.Month, CalendarView.Day)
.inOrder()
}
@Test
fun `drilling is a no-op when the day view is already current`() {
val stack = listOf(CalendarView.Week, CalendarView.Day)
assertThat(stack.drillToDay()).isEqualTo(stack)
}
@Test
fun `agenda widget date tap backs out agenda then home`() {
// openDate(source = Agenda): root in Agenda over the Week home, then drill.
val stack = viewBaseStack(CalendarView.Week, CalendarView.Agenda).drillToDay()
assertThat(stack)
.containsExactly(CalendarView.Week, CalendarView.Agenda, CalendarView.Day)
.inOrder()
// Pressing back pops one level at a time down to the home view.
assertThat(stack.dropLast(1)).containsExactly(CalendarView.Week, CalendarView.Agenda).inOrder()
assertThat(stack.dropLast(2)).containsExactly(CalendarView.Week)
}
@Test
fun `month widget date tap backs out month then home`() {
val stack = viewBaseStack(CalendarView.Week, CalendarView.Month).drillToDay()
assertThat(stack.last()).isEqualTo(CalendarView.Day)
assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Month)
assertThat(stack.first()).isEqualTo(CalendarView.Week)
}
@Test
fun `a widget whose view is the home view does not duplicate the home`() {
// Default = Agenda, agenda widget event tap → just the home, no extra layer.
assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda))
.containsExactly(CalendarView.Agenda)
}
}

View File

@@ -143,6 +143,5 @@ 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` once per pull request; merging a
bumped `versionName` to `main` builds, signs, and publishes to the self-hosted
F-Droid repo and then mints the `vX.Y.Z` tag + release. See docs/RELEASING.md.
(Gitea Actions) runs `lint test assembleDebug` on every push; release tags
additionally build, sign, and publish to the self-hosted F-Droid repo.

View File

@@ -12,9 +12,7 @@ Where to look for what:
| [`../.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 |
| [`../fastlane/metadata/android/`](../fastlane/metadata/android/) | Store metadata (single source of truth): descriptions, title, icon, screenshots (DE + EN). Harvested directly by the official F-Droid repo; transformed into the self-hosted repo layout at release time by [`../scripts/fastlane_to_fdroid_localized.sh`](../scripts/fastlane_to_fdroid_localized.sh) |
| [`../fdroid-metadata/`](../fdroid-metadata/) | App-level F-Droid control file (`*.yml`: Categories, License, links) for the self-hosted repo's `fdroid update` |
| [`fdroid-official/`](fdroid-official/) | Draft recipe + notes for publishing to the **official** F-Droid repo (reproducible build + developer-signed binary) |
| [`../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

View File

@@ -1,98 +1,61 @@
# Releasing Calendula
Calendula is distributed through a self-hosted F-Droid repository. A release is
built, signed, and published automatically by `.gitea/workflows/release.yaml`
when a **bumped `versionName` reaches `main`** — the pipeline then creates the
matching `vX.Y.Z` tag and Gitea release itself.
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 committed version is the source of truth
## Versioning — the git tag is the single source of truth
A release is defined by the `versionName`/`versionCode` committed in
`app/build.gradle.kts`:
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` = `MAJOR.MINOR.PATCH` (e.g. `2.1.0`)
- `versionName` = the tag without the leading `v` (`2.1.0`)
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`2.1.0``20100`)
So `MINOR` and `PATCH` each have room for 099. The release pipeline reads
`versionName`, pins `versionCode` to the derived value, builds, and — once the
APK is published — creates the tag `v<versionName>` at that commit. The tag is
an **output** of a successful release, not its trigger, so a tag always marks a
fully-shipped version (and a failure before publish leaves no tag, so re-running
the workflow safely retries).
So `MINOR` and `PATCH` each have room for 099. The values committed in
`app/build.gradle.kts` are only the dev/local default — CI overwrites them
from the tag. Keep the committed `versionCode`/`versionName` matching the
**latest released tag** so local builds are sanely versioned; the published
value always comes from the tag.
Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`→20000.
## Cutting a release
1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the
feature/fix branches that make up this release into it. This branch is the
release candidate — everything below happens on it, before it reaches `main`.
2. Move the `## [Unreleased]` section of `CHANGELOG.md` under a new
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.
3. Bump the committed `versionName` (and `versionCode`) in
`app/build.gradle.kts` to the new version. **This bump is what triggers the
release** when the branch merges to `main`. Then run
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
scripts/sync_changelog_to_fastlane.sh
git tag vX.Y.Z
git push origin vX.Y.Z
```
and commit the generated
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`. This is what
makes the **official** F-Droid repo show this version's changelog (it reads
the changelog from the tagged source tree). The self-hosted pipeline
regenerates it regardless, so forgetting only affects the official listing.
4. **Verify the release build on a real device** — the mandatory gate. The
shipped APK is R8-shrunk/obfuscated, and bugs that only appear there, or
only on first run, never show up in the debug build or on a device that
already granted the calendar permission (this is how the v2.7.0 launch
crash slipped through). Run:
```bash
scripts/verify-release.sh
```
It builds the `releaseTest` variant (same R8 config as `release`, debug-signed
with a `.releasetest` suffix so it installs alongside the real app) and resets
it to a first-run state. Then, on the device:
- launch from a **clean / permission-not-granted** state — the permission
screen must appear, no crash;
- grant access — the calendar must load;
- add both home-screen **widgets** and confirm they render;
- exercise this release's headline changes.
Only proceed once all of that passes on-device.
5. **Merge `release/vX.Y.Z` into `main`.** That's it — no manual tagging. The
merge triggers `release.yaml`, which detects the new version, builds, signs,
publishes to F-Droid, and creates the `vX.Y.Z` tag + Gitea release. **Hold UI
releases for on-device review and explicit go-ahead before merging.**
> The `releaseTest` build type exists only for step 4 — it is never published.
> The pipeline always builds and signs the real `release` variant.
4. The push triggers the release workflow. **Hold UI releases for on-device
review and explicit go-ahead before tagging.**
## What the pipeline does
CI and release are split so a change is built once on its PR and only does
release work when a merge actually cuts a release:
`release.yaml` has three jobs:
- **`ci.yaml`** (on `pull_request`) — lint + unit tests + a debug assemble (and
a Trivy scan), once per PR. Docs/metadata-only PRs skip the Android build but
still report a green `CI` check.
- **`release.yaml`** (on push to `main`, plus `workflow_dispatch`) — a cheap
`detect` job reads `versionName` and checks whether a tag for it already
exists. Only when it doesn't does the `release` job run: unit tests on the
merged commit, build & sign the release APK with the **app key**, copy it into
the F-Droid repo, generate the per-version changelog, re-sign the index with
the **repo key**, upload `repo/` + `metadata/`, then create the `vX.Y.Z` tag +
Gitea release (CHANGELOG section as notes) and attach the R8 `mapping.txt`
(best-effort). Ordinary merges with no version bump fall through `detect` and
do nothing.
- **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 runs a **re-sign-only**
path: `detect` reports it's not a release, so the `release` job skips the APK
build, the version bump, and tag/release creation, 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.
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)

View File

@@ -1,94 +0,0 @@
# Official F-Droid submission (draft)
Goal: publish Calendula to the **official F-Droid repo** *alongside* the
self-hosted repo, as **one app** — same signed binary in both channels, so
existing self-hosted users migrate with no reinstall and no data loss.
This folder holds the **draft** fdroiddata recipe. Nothing here is submitted
yet. The self-hosted pipeline (`../../fdroid-metadata/`, `.gitea/workflows/release.yaml`)
is unaffected by these files.
## Publishing model: reproducible build + developer-signed binary
F-Droid builds Calendula from source on its buildserver, then verifies the
output is byte-for-byte identical to **our** signed APK (fetched via the
`Binaries` URL). On a match it publishes **our** binary, signed with **our**
key — identified by `AllowedAPKSigningKeys`. Result: official and self-hosted
both carry the same signature. If a build ever fails to match, F-Droid simply
skips that version (fails safe — no bad publish).
## Verification status (2026-06-21)
-**Run-to-run reproducible**`v2.7.0` built twice from a clean worktree
(R8 minify + resource-shrink, build cache off) → byte-for-byte identical
(`sha256 568c944a…`).
-**Cross-JDK reproducible** — rebuilt with JDK 17 and JDK 21 → identical
APK. JDK version is not a sensitivity, so it need not be pinned.
-**Tag self-consistent** — committed `versionCode`/`versionName` already
equal the tag-derived values, so F-Droid building the tag as-is matches the
CI-published binary (CI's `sed` substitution is a no-op).
-**App signing cert SHA-256 extracted** from published APKs (identical
across v1.0.0 / v2.0.0 / v2.4.0): `5cdaee8e…`. No keystore needed.
-**Eligibility** — MIT, 100% FOSS deps (no Play Services / Firebase /
analytics / billing), no `INTERNET` permission, no native code.
-**End-to-end reproducible vs the DISTRIBUTED binary** — built tag `v2.7.1`
from source and compared against the published `calendula_v2.7.1.apk`: every
app entry (dex / resources / assets / manifest) byte-identical. The only
difference was `META-INF/version-control-info.textproto` (AGP's git metadata,
env-dependent). Fixed with `vcsInfo { include = false }` on the release build;
re-validated → 0 non-signature differences. **Takes effect from the first
release built after that change — submit the recipe starting at that version**
(v2.7.1 and earlier still embed the textproto and won't verify).
- ⚠️ **Not yet proven**: cross-host / fixed-build-path reproducibility on
F-Droid's buildserver (low risk for a no-NDK pure-JVM app; with vcsInfo
disabled, the env-dependent field is gone; F-Droid confirms at review).
-**Buildserver toolchain supported** (checked 2026-06-21):
- **Gradle 9.5.1** is in F-Droid's gradle-transparency-log (`checksums.json`)
with a verified sha256, so `gradlew.py` will download + verify + run it.
- **AGP 9.2** is in `gradlew.py`'s `MIN_GRADLE_VERSION` map (`9.2 -> 9.4.1`).
- **JDK 17** is standard on the buildserver (AGP 9.2 requires exactly 17).
- **build-tools 36.0.0 / android-37**: not statically preinstalled (baseline
stops at 33), but `provision-android-sdk` makes `$ANDROID_HOME/build-tools`
and `/platforms` group-writable so AGP/Gradle install newer ones on demand.
## Before submitting — checklist
1. Confirm the `Binaries` URL is publicly reachable and stable long-term, e.g.
`https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo/calendula_v2.7.0.apk`
resolves to the dev-signed APK. F-Droid re-fetches it on every build.
2. Confirm F-Droid's buildserver supports the toolchain (see timing risk).
3. (Recommended) Reproduce one published release end-to-end: build the tag from
source, strip signatures, and diff against the downloaded `calendula_v<ver>.apk`
to confirm the from-source build matches the *distributed* binary — not just
another local build.
## Submission steps (fdroiddata, GitLab)
1. Fork `https://gitlab.com/fdroid/fdroiddata`.
2. Copy `de.jeanlucmakiola.calendula.yml` to `metadata/` in the fork.
3. Test locally with `fdroid build -v de.jeanlucmakiola.calendula` and
`fdroid lint de.jeanlucmakiola.calendula` (and `fdroid readmeta`).
4. Open a Merge Request. Initial review can take weeks to months; the
self-hosted repo keeps serving in the meantime, so there's no rush.
## Listing metadata (descriptions, screenshots, icon)
These are **not** in the recipe `.yml`. F-Droid's `fdroid update` harvests them
automatically from the fastlane tree in the app's source repo:
fastlane/metadata/android/<locale>/
short_description.txt full_description.txt title.txt
images/icon.png images/phoneScreenshots/*.png
changelogs/<versionCode>.txt (optional, per release)
This is the single source of truth: en-US + de-DE already exist there, and the
self-hosted repo is generated from the same tree at release time via
`scripts/fastlane_to_fdroid_localized.sh`. Nothing extra to add to fdroiddata —
F-Droid picks the listing up from source. (Screenshots may not be committed to
the fdroiddata repo anyway, so the source-tree fastlane layout is required.)
Per-version changelogs: the self-hosted release workflow generates
`changelogs/<versionCode>.txt` at release time. For the official repo to show a
changelog, that file must be committed into `fastlane/metadata/android/<locale>/changelogs/`
at the tagged commit — wire this into the release/version-bump step, or accept
no in-client changelog for the official listing initially.

View File

@@ -1,65 +0,0 @@
# ---------------------------------------------------------------------------
# DRAFT fdroiddata metadata for the OFFICIAL F-Droid repository.
#
# This is NOT the self-hosted metadata (that lives in ../../fdroid-metadata/).
# When submitting, this file's contents go to fdroiddata on GitLab as
# metadata/de.jeanlucmakiola.calendula.yml
# See README.md in this folder for the verification status and submission steps.
#
# Publishing model: REPRODUCIBLE BUILD + developer-signed binary.
# F-Droid builds from source on its buildserver, then verifies the result is
# identical to our own signed APK (fetched via `Binaries`). If it matches,
# F-Droid publishes OUR binary signed with OUR key (`AllowedAPKSigningKeys`),
# so the official and self-hosted channels carry the SAME signature and users
# migrate between them with no reinstall / no data loss.
# ---------------------------------------------------------------------------
Categories:
- Calendar & Agenda
License: MIT
AuthorName: Jean-Luc Makiola
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
Changelog: https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/CHANGELOG.md
AutoName: Calendula
RepoType: git
Repo: https://gitea.jeanlucmakiola.de/makiolaj/calendula.git
# First build entry = v2.7.5, the first release that clears ALL three F-Droid
# blockers: (1) AGP VCS-info disabled (`vcsInfo { include = false }`, since
# v2.7.3); (2) the unused Gradle foojay toolchain-resolver plugin removed (since
# v2.7.4) — the offline build scanner rejects it as it can fetch a JDK; (3) AGP's
# dependency-metadata block no longer embedded (`dependenciesInfo { includeInApk
# = false }`) — the binary scanner rejects it as an "extra signing block". All
# three live outside the zip entries or are inert, so v2.7.5 is functionally
# identical to v2.7.3 and reproduces byte-for-byte from source. v2.7.4 and
# earlier trip one of these checks — do not target them. Subsequent versions are
# added automatically (AutoUpdateMode).
Builds:
- versionName: 2.7.5
versionCode: 20705
commit: v2.7.5
subdir: app
gradle:
- yes
# No NDK / no flavors. The release buildType applies a signingConfig only
# when key.properties exists; on the buildserver it does not, so this
# produces the unsigned APK F-Droid compares against our binary.
# SHA-256 of our app signing certificate (public; embedded in every published
# APK). Locks F-Droid to publish only binaries signed with our key.
AllowedAPKSigningKeys: 5cdaee8eb31cb0df9157c646ae3ec8b3dc43b5bb72624e088c9ddea0c4eb5ec1
# Our own developer-signed APK for reproducible-build verification. %v -> the
# versionName, so v2.7.5 resolves to calendula_v2.7.5.apk on the self-hosted repo.
Binaries: https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo/calendula_v%v.apk
# After this one-time submission F-Droid auto-tracks new vX.Y.Z tags and creates
# build entries itself — no manual recipe edits per release. The tag regex keeps
# it to release tags only.
AutoUpdateMode: Version
UpdateCheckMode: Tags ^v[0-9.]+$
CurrentVersion: 2.7.5
CurrentVersionCode: 20705

View File

@@ -1 +0,0 @@
Calendula

View File

@@ -1,8 +0,0 @@
### Fixed
- Fixed the app crashing immediately on launch whenever calendar access hadn't
been granted yet (a fresh install, or after revoking the permission). The app
set up its live calendar-change listener before the permission screen could
appear, which newer Android versions reject outright — so the app died before
you could grant access. The listener now waits for the permission and attaches
itself the moment it's granted.

View File

@@ -1,6 +0,0 @@
### Fixed
- Home-screen widgets no longer get stuck on a loading spinner in the published
(F-Droid) release build. They render via Android's background-work system, and
release optimisation (R8) was stripping a helper class it loads by name, so the
render job never ran. Added the missing keep rule — widgets now load normally.

View File

@@ -1,6 +0,0 @@
### Changed
- Build cleanup that lets Calendula ship in the official F-Droid repository:
removed an unused Gradle toolchain-resolver plugin, which F-Droid's offline,
reproducible build process disallows. No functional or visible changes — this
is the same app as 2.7.3.

View File

@@ -1,6 +0,0 @@
### Changed
- Further build cleanup for the official F-Droid repository: stopped embedding
AGP's dependency-metadata block in the APK, which F-Droid's reproducible-build
scanner rejects as an extra signing block. No functional or visible changes —
the same app as 2.7.4, just without that Play-oriented metadata blob.

View File

@@ -1,38 +0,0 @@
### Added
- Find events fast. A search button in the top bar of every calendar view opens
a search box — type a couple of letters and matching events (by title,
location or description) appear, soonest first with past events below. Tap a
result to open it. Search covers your whole calendar, not just what's on
screen, and skips calendars you've hidden. A recurring event shows its next
occurrence rather than the date the series first started.
- A current-time line in the day and week views. A thin coloured line marks the
present moment across today's column, so you can see at a glance where you are
in the day. It updates every minute and only appears when today is in view.
- Add and remove event guests. The create/edit form now has a Guests section:
add people by email or pick them from your contacts, mark each as required or
optional, and remove them. Calendula never sends invitations itself (it has no
internet access) — it only records the guests; if the event lives on a synced
account, that account may email them when it syncs, and on a local calendar no
one is notified. The form tells you which applies. Picking a guest from
contacts needs no contacts permission.
- Pick a location from your contacts. A contacts button beside the location
field drops a contact's address straight into an event — handy for a meeting
at someone's home or office. Like the guest picker, it needs no contacts
permission.
- A "New event" Quick Settings tile. Add it to your quick settings to jump
straight into the new-event form from anywhere. Settings → New event has a
one-tap button to add the tile (Android 13+); on older versions you can add it
from the quick-settings editor.
- Snooze and dismiss buttons on reminder notifications. Dismiss clears the
reminder; snooze hides it and brings it back after a delay you pick in
Settings → Notifications (5 to 60 minutes, default 10). Android's calendar
system won't re-post a reminder on its own, so Calendula schedules an exact
alarm to bring a snoozed one back on time.
### Changed
- Event details now show each guest's email beneath their name, instead of only
when no name is available.
- Crash and problem reports now open on the project's public Codeberg tracker,
where anyone can register and file an issue. Nothing is sent automatically —
you still review the report and submit it yourself in the browser.

View File

@@ -1,33 +0,0 @@
### Added
- Choose the view Calendula opens on. A new **Default view** setting (Settings →
Appearance) lets you pick Month, Week, Day or Agenda as the view shown each
time you start the app, instead of always opening on Week. Thanks to
@devinside for the suggestion ([#1]).
- The month widget is now interactive: tap any day to open it, or tap an event to
open its details. Previously only the month's prev/next/today controls
responded. Thanks to @devinside for spotting this ([#2]).
- Predictive back. Swiping back from a screen — an event, the editor, search or
settings — now follows your finger, shrinking the screen to preview what's
behind so you can see where Back will take you before you let go; slide it back
to cancel. The preview needs Android 14 or newer; on older versions Back works
as before.
- Respect for "Remove animations". If you've turned animations off in your
device's Accessibility settings, Calendula now honours it everywhere: motion
collapses to a quick fade and the back preview is skipped.
### Changed
- Smoother, more consistent motion throughout. Expanding sections, list updates
in search and agenda, and the onboarding screens now animate the same way
across the app, instead of some places sliding or growing while others popped
in. Switching between Month, Week, Day and Agenda now cross-fades rather than
snapping, while paging within a view keeps its slide.
### Fixed
- Home-screen widgets now back out the way you came in. A day or event opened
from the Agenda widget keeps you in the agenda context, and from the Month
widget in the month context — pressing Back returns there instead of dropping
you on the week view. More broadly, the app now keeps a real view history:
switching views and drilling into a day are retraced by Back one step at a
time, down to your default view before the app exits. Thanks to @devinside for
reporting ([#2]).

View File

@@ -1 +0,0 @@
Calendula

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,6 +1,6 @@
[versions]
agp = "9.2.1"
kotlin = "2.3.21"
kotlin = "2.4.0"
ksp = "2.3.9"
hilt = "2.59.2"
coreKtx = "1.19.0"

View File

@@ -1,64 +0,0 @@
#!/usr/bin/env bash
# Reproducibility guard for the official F-Droid repo (de.jeanlucmakiola.calendula).
#
# F-Droid only republishes OUR signed binary if a from-source build reproduces it
# byte-for-byte and the binary carries no extra signing blocks. If any invariant
# below regresses, the official repo silently stalls on the last good version
# (fails safe — but you'd be stuck on an old release without noticing). So fail
# loudly here, on every PR.
#
# Each invariant was learned from a real fdroiddata CI rejection (see
# docs/fdroid-official/ and the v2.7.3 -> v2.7.5 history):
# 1. vcsInfo { include = false } — else AGP embeds env-dependent git
# metadata (META-INF/version-control-info.textproto) -> not reproducible.
# 2. no foojay toolchain resolver — F-Droid's offline source scanner
# rejects org.gradle.toolchains.foojay-resolver (it can fetch a JDK over
# the network at build time).
# 3. dependenciesInfo { includeInApk = false } — else AGP embeds a "Dependency
# metadata" block (id 0x504b4453) in the APK Signing Block, which F-Droid's
# binary scanner rejects as an extra signing block.
set -euo pipefail
APP="app/build.gradle.kts"
SETTINGS="settings.gradle.kts"
fail=0
# 1. AGP VCS-info must be disabled on the release build. -z reads the whole file
# as one record so the match can span newlines; [^}] keeps it inside the block.
if grep -Pzoq 'vcsInfo\s*\{[^}]*include\s*=\s*false' "$APP"; then
echo "OK: vcsInfo { include = false } — no env-dependent VCS metadata embedded."
else
echo "ERROR: '$APP' release build is missing 'vcsInfo { include = false }'." >&2
echo " AGP would embed version-control-info.textproto, breaking reproducibility." >&2
fail=1
fi
# 2. The foojay toolchain resolver must not be present in any Gradle script.
gradle_files=("$SETTINGS" "$APP")
[ -f build.gradle.kts ] && gradle_files+=(build.gradle.kts)
if grep -qi 'foojay' "${gradle_files[@]}"; then
echo "ERROR: foojay toolchain resolver found in: $(grep -li foojay "${gradle_files[@]}" | tr '\n' ' ')" >&2
echo " F-Droid's source scanner rejects org.gradle.toolchains.foojay-resolver" >&2
echo " (it can fetch a JDK over the network). Remove the plugin." >&2
fail=1
else
echo "OK: no foojay toolchain resolver — offline build scanner stays happy."
fi
# 3. AGP dependency-metadata block must not be embedded in the APK.
if grep -Pzoq 'dependenciesInfo\s*\{[^}]*includeInApk\s*=\s*false' "$APP"; then
echo "OK: dependenciesInfo { includeInApk = false } — no extra APK signing block."
else
echo "ERROR: '$APP' is missing 'dependenciesInfo { includeInApk = false }'." >&2
echo " AGP would embed a 'Dependency metadata' block (0x504b4453) in the APK" >&2
echo " Signing Block, which F-Droid's binary scanner rejects." >&2
fail=1
fi
if [ "$fail" -ne 0 ]; then
echo >&2
echo "Reproducible-release invariant(s) violated — official F-Droid publishing would" >&2
echo "stall. Fix the above before merging. See docs/fdroid-official/." >&2
exit 1
fi
echo "All reproducible-release invariants hold."

Some files were not shown because too many files have changed in this diff Show More