Compare commits

..
Author SHA1 Message Date
renovate-bot f70aa6a358 fix(deps): update appcompat to v1.8.0 2026-09-07 05:02:38 +00:00
546 changed files with 10037 additions and 24942 deletions
+2 -2
View File
@@ -21,9 +21,9 @@ labels:
- Calendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:
- Installed from: <!-- official F-Droid / the self-hosted repo / Google Play / Codeberg APK or Obtainium / built from source -->
- Installed from: <!-- official F-Droid / the self-hosted repo / built from source -->
- Affected calendar: <!-- Google, CalDAV (DAVx5, Nextcloud, …), on-device/local,
subscribed/WebCal, birthdays. Provider behaviour differs
subscribed/WebCal, birthdays — provider behaviour differs
a lot per account type, so this often points straight at
the cause -->
- Time zone: <!-- only if the problem involves dates or all-day events -->
+2 -2
View File
@@ -6,8 +6,8 @@ contact_links:
- name: Translate Calendula
url: https://weblate.dev.jeanlucmakiola.de/engage/calendula/
about: >-
Translations are managed on Weblate. It owns every values-* file, so a
hand-edited translation gets overwritten on the next sync.
Translations are managed on Weblate, not here — it owns every values-*
file, so a hand-edited translation gets overwritten on the next sync.
No coding needed: pick or request a language and translate in the browser.
- name: Contributing guide
+3 -3
View File
@@ -1,6 +1,6 @@
---
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.
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
@@ -11,10 +11,10 @@ labels:
<!--
Thanks for reporting a crash in Calendula!
If the app prefilled this for you, the crash report is already below. Add
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, with no personal data or calendar content.
stack trace — no personal data or calendar content.
-->
### What happened
+1 -1
View File
@@ -9,7 +9,7 @@ labels:
### What would you like Calendula to do?
### What problem does it solve?
### Why — what problem does it solve?
### Anything else
+3 -3
View File
@@ -5,7 +5,7 @@ Please skim CONTRIBUTING.md if you haven't:
https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
Two things it's easy to get wrong:
• Features need a discussed issue first. An undiscussed feature PR may be
• Features need a discussed issue first — an undiscussed feature PR may be
closed unmerged even when the code is good.
• Target the release branch for your issue's milestone (milestone 2.18.0 →
release/v2.18.0), not main. If you targeted main, just say so below and it
@@ -17,7 +17,7 @@ Two things it's easy to get wrong:
### Why
<!-- Closes #123: link the issue this implements or fixes. -->
<!-- Closes #123 — link the issue this implements or fixes. -->
### How it was tested
@@ -34,7 +34,7 @@ are especially useful for UI changes.
### Checklist
- [ ] There's an issue for this, and (for a feature) it got a go-ahead
- [ ] Targeting the release branch for that issue's milestone (or `main`, noted above)
- [ ] Targeting the release branch for that issue's milestone — or `main`, noted above
- [ ] `./gradlew lint test assembleDebug` passes locally
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible
-34
View File
@@ -37,40 +37,6 @@ jobs:
- name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh
# versionCode must match versionName (the official F-Droid repo builds the
# tag as committed), and a beta must never reach main.
- name: Committed version is well-formed
env:
BASE: ${{ github.base_ref }}
run: |
set -e
bash scripts/version_info.sh --check
if [ "${BASE#refs/heads/}" = "main" ] && [ "$(bash scripts/version_info.sh channel)" = "beta" ]; then
echo "ERROR: versionName $(bash scripts/version_info.sh version) is a beta." >&2
echo "Set the stable version (and its versionCode) before merging into main." >&2
exit 1
fi
# Play rejects a "What's New" over 500 characters, which would fail the
# upload after the release had already shipped everywhere else. Cheap, so
# it runs on every PR rather than only on the release merge. A beta ships
# no What's New, so only the older files are checked for one.
- name: Changelog length invariant
run: bash scripts/check_changelog_lengths.sh
# The whole listing goes to Play with every release; a field over its cap
# or an image Play rejects would fail that upload.
- name: Store listing invariant
run: |
if ! command -v python3 >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
apt-get update && apt-get install -y python3
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache python3
fi
fi
python3 scripts/check_store_listing.py
# Decide whether anything that affects the app build changed. Docs, store
# metadata, licence texts and forge housekeeping don't, so those PRs skip
# the SDK + Gradle work below but still report a green `ci`.
-57
View File
@@ -1,57 +0,0 @@
name: Android build environment
description: JDK 17, Android SDK + build tools, Gradle cache and jq for the release and beta pipelines.
runs:
using: composite
steps:
- 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
shell: bash
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: Install jq
shell: bash
run: |
set -e
SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update
$SUDO apt-get install -y jq
elif command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache jq
fi
- name: Grant execute permission for gradlew
shell: bash
run: chmod +x ./gradlew
-97
View File
@@ -1,97 +0,0 @@
name: Beta — Codeberg pre-release
# Pushing a release/* branch whose versionName is X.Y.Z-beta.N publishes a
# Codeberg pre-release (APK + SHA-256) and a Gitea pre-release (R8 mapping).
# Betas never reach F-Droid or Play. See docs/RELEASING.md.
on:
push:
branches: ['release/**']
concurrency:
group: beta
cancel-in-progress: false
jobs:
detect:
# Gitea only; see the same guard in release.yaml.
if: github.repository_owner == 'makiolaj'
runs-on: light
outputs:
is_beta: ${{ steps.v.outputs.is_beta }}
version: ${{ steps.v.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve version and whether it still needs publishing
id: v
run: |
set -e
INFO=$(bash scripts/version_info.sh)
echo "$INFO"
echo "$INFO" >> "$GITHUB_OUTPUT"
if [ "$(bash scripts/version_info.sh channel)" != beta ]; then
echo "Not a beta — nothing to do."
echo "is_beta=false" >> "$GITHUB_OUTPUT"
exit 0
fi
GATE=$(bash scripts/release_gate.sh)
echo "is_beta=${GATE#cut=}" >> "$GITHUB_OUTPUT"
beta:
needs: detect
if: needs.detect.outputs.is_beta == 'true'
runs-on: gradle
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
VERSION: ${{ needs.detect.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Android build environment
uses: ./.gitea/actions/android-env
- name: Pin versionCode to versionName
run: bash scripts/version_info.sh --pin
- name: Unit tests
run: ./gradlew testDebugUnitTest
# The real app key, so a beta updates in place to later betas and stable.
- name: Setup Android Keystore
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
run: bash scripts/write_keystore.sh
- name: Build release APK
run: ./gradlew assembleRelease
- name: Create tag + Gitea pre-release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
SHA: ${{ github.sha }}
run: |
set -e
bash scripts/release_notes.sh "$VERSION" > release-notes.md
cat release-notes.md
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/release/mapping.txt \
bash scripts/publish_gitea_release.sh
- name: Publish pre-release to Codeberg
env:
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
SHA: ${{ github.sha }}
run: |
set -e
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/release/app-release.apk \
bash scripts/publish_codeberg_release.sh
+257 -34
View File
@@ -7,9 +7,7 @@ name: Release — F-Droid repo + Gitea/Codeberg release + Play
# that release to Codeberg with the signed APK + a SHA-256 checksum as a
# direct-download channel — the tag is an output of the pipeline, not its
# trigger. Ordinary merges (no version bump) fall through `detect` and do
# nothing. Betas (X.Y.Z-beta.N) never come through here: beta.yaml cuts them
# from release/* branches as Codeberg-only pre-releases, and `detect` refuses
# one that reaches main.
# nothing.
#
# A trailing `play` job then uploads the App Bundle to Google Play. It is last
# and separate because Play is the only channel that can reject a good build for
@@ -43,7 +41,7 @@ jobs:
# secrets. repository_owner differs between the two forges regardless of
# URL, proxy or instance rename, so this closes it permanently.
if: github.repository_owner == 'makiolaj'
runs-on: light
runs-on: docker
outputs:
is_release: ${{ steps.v.outputs.is_release }}
version: ${{ steps.v.outputs.version }}
@@ -56,28 +54,56 @@ jobs:
- name: Resolve version and whether it is a new release
id: v
env:
# Tags are read from Codeberg, which is canonical — deliberately NOT
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
# source does not have. A tag minted here on Gitea is therefore wiped
# by the next sync (Codeberg does not have it yet) and only reappears
# once the tag push at the end of this workflow propagates back.
# Asking Gitea inside that window would report "no tag" for a release
# that already shipped, and cut it a second time.
# Public repo, so this read needs no token.
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
run: |
set -e
INFO=$(bash scripts/version_info.sh)
echo "$INFO"
echo "$INFO" >> "$GITHUB_OUTPUT"
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
CHANNEL=$(echo "$INFO" | sed -n 's/^channel=//p')
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
# Backstop for CI's guard: betas ship from release/* via beta.yaml.
if [ "$CHANNEL" != "stable" ]; then
echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2
exit 1
fi
# Tags are read from Codeberg, the canonical forge: a tag minted here is
# wiped by the next mirror sync until Codeberg has it. A lookup error is
# fatal, since guessing "no tag" would re-cut a shipped release.
GATE=$(bash scripts/release_gate.sh)
echo "is_release=${GATE#cut=}" >> "$GITHUB_OUTPUT"
# A tag for this version already existing means the release shipped on
# an earlier push; do nothing. Absent => this merge cuts the release.
#
# Anything other than a clean 200/404 is treated as fatal rather than
# as "no tag". A Codeberg outage or a network blip would otherwise
# read as absent and re-cut a release that has already shipped —
# republishing to F-Droid and Play. Failing here is recoverable; a
# duplicate release is not.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
case "$STATUS" in
200)
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
;;
404)
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
echo "is_release=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
exit 1
;;
esac
# 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
@@ -85,7 +111,7 @@ jobs:
release:
needs: detect
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
runs-on: gradle
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
@@ -98,13 +124,65 @@ jobs:
with:
submodules: recursive
- name: Android build environment
uses: ./.gitea/actions/android-env
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
# The committed versionName is the source of truth; pin the derived code.
- 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: Install jq
run: |
set -e
SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update
$SUDO apt-get install -y jq
elif command -v apk >/dev/null 2>&1; then
$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'
run: bash scripts/version_info.sh --pin
run: |
set -e
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
@@ -117,7 +195,15 @@ jobs:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
run: bash scripts/write_keystore.sh
run: |
mkdir -p app
echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks
cat > key.properties <<EOF
storePassword=$KEY_PASSWORD
keyPassword=$KEY_PASSWORD
keyAlias=$KEY_ALIAS
storeFile=upload-keystore.jks
EOF
- name: Build release APK
if: env.IS_RELEASE == 'true'
@@ -226,7 +312,6 @@ jobs:
# 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).
# Also attaches the R8 mapping, best-effort.
- name: Create tag + Gitea release
if: env.IS_RELEASE == 'true'
env:
@@ -235,10 +320,74 @@ jobs:
SHA: ${{ github.sha }}
run: |
set -e
bash scripts/release_notes.sh "$VERSION" > release-notes.md
TAG="v$VERSION" PRERELEASE=false NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/release/mapping.txt \
bash scripts/publish_gitea_release.sh
TAG="v$VERSION"
# Notes = this version's CHANGELOG section.
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
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
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).
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
if [ -n "$ID" ]; then
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @payload.json "$API/releases/$ID")
OK=200
else
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @payload.json "$API/releases")
OK=201
fi
cat response.json
if [ "$CODE" != "$OK" ]; then
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
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"
# Mirror the release to the Codeberg mirror as a direct-download channel
# for users who don't want F-Droid. Gitea already push-mirrors branches +
@@ -257,10 +406,84 @@ jobs:
SHA: ${{ github.sha }}
run: |
set -e
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
TAG="v$VERSION" PRERELEASE=false NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/release/app-release.apk \
bash scripts/publish_codeberg_release.sh
if [ -z "${TOKEN:-}" ]; then
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
exit 0
fi
TAG="v$VERSION"
APK="app/build/outputs/apk/release/app-release.apk"
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
ASSET_APK="calendula_v${VERSION}.apk"
ASSET_SUM="${ASSET_APK}.sha256"
cp "$APK" "/tmp/$ASSET_APK"
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
# Release notes: reuse the section extracted for the Gitea release,
# fall back to the CHANGELOG entry if that step's file is gone.
if [ ! -s release-notes.md ]; then
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
sed -i -e '/./,$!d' release-notes.md
fi
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
# The pipeline creates the tag via the Gitea API, which the push mirror
# (sync_on_commit only fires on real git pushes) doesn't propagate
# promptly — so a release POST that carries a target_commitish can
# outrun the mirror and 500 on a commit/tag Codeberg hasn't received.
# Push the tag straight to Codeberg so it's guaranteed present, then
# attach the release to that existing tag with NO target_commitish
# (which is what triggered the 500).
git tag -f "$TAG" "$SHA"
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/calendula.git" \
"refs/tags/$TAG"
python3 - "$TAG" <<'PY' > cb-payload.json
import json, sys
print(json.dumps({
"tag_name": sys.argv[1],
"name": sys.argv[1],
"body": open("release-notes.md").read(),
"draft": False,
"prerelease": False,
}))
PY
# Create (or update) the release. Codeberg 500s on a POST/GET against a
# tag it has only just received — the release request outruns the
# indexing of the ref we pushed a moment ago — so a single attempt kept
# failing and skipping the mirror even though the very same call
# succeeds seconds later. Retry with backoff, and PATCH in place if a
# release already exists (re-run safe). A 5xx body still exits curl 0,
# so the loop, not `set -e`, controls the flow.
ID=""
for attempt in 1 2 3 4 5 6; do
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
if [ -n "$EXIST" ]; then
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases/$EXIST"
ID="$EXIST"; break
fi
CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases")
echo "release POST attempt $attempt HTTP $CODE"
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
[ -n "$ID" ] && break
sleep $((attempt * 10))
done
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
# Attach APK + checksum, replacing any prior asset of the same name.
for A in "$ASSET_APK" "$ASSET_SUM"; do
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$A" '.[] | 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/$A" \
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
done
echo "Published $TAG to Codeberg."
# Play takes an App Bundle, not the APK, so it is a second artifact from
# the same source and the same signing config — not a repackage of the
+2 -11
View File
@@ -1,17 +1,8 @@
name: Renovate
on:
# Every merge to main. Mirror syncs from Codeberg fire push events here (the
# same trigger release.yaml relies on), so a merged Renovate PR is followed
# within minutes by a run that rebases the sibling PRs it just conflicted —
# most bumps touch gradle/libs.versions.toml. Outside renovate.json5's
# `schedule` window such a run only maintains existing branches
# (updateNotScheduled), it never opens new PRs. Renovate's own rebases push
# to renovate/* branches, not main, so this cannot loop.
push:
branches: [main]
# Weekly sweep for new updates. Mondays 05:00 UTC, inside the schedule window
# in renovate.json5 — keep the two in step.
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
schedule:
- cron: '0 5 * * 1'
# Manual run for an on-demand sweep from the Actions tab.
-303
View File
@@ -7,248 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Long press an event to select it.** In the day, 3-day and week views, a long press lifts the event and selects it. Keep your finger down and move to drag it straight away. A selected event has handles at its top and bottom: drag them to change when it starts or ends, in 15-minute steps and across midnight if you want. The bar at the top edits, duplicates, shares or deletes it, and tapping elsewhere or going back deselects it. Read-only events can be selected but not moved or resized ([#141]).
- **Long press an empty slot to draft an event.** In the day, 3-day and week views, a long press on free time drops a block of your default event length there. Move it while still holding, or let go and drag it or its handles into place, then tap the tick to open a new event at that time. The ✕, a tap elsewhere or going back throws the draft away ([#153]).
- **Tap a phone number in an event to call it.** Phone numbers in an event's title, location and description are now links. Tapping one opens your phone app with the number filled in, ready to call. A conference dial-in like "+1 646 558 8656,,850123#" brings its meeting ID along. Meeting IDs and PINs on their own, dates, times and amounts stay plain text. Long-pressing a field copies it, even when you press on a link ([#339]).
- **Formatted event descriptions read properly.** Descriptions from Outlook, Exchange and Google Calendar are often stored as HTML, which used to show up as a wall of tags. The event view now shows them as formatted text: bold, italics, lists and links work, table rows sit on one line each, and the fonts and colours the sender picked are left out so the text matches the app. Search matches the text you see. The edit form still shows the original, so saving an event never changes its formatting ([#271]).
- **Scroll back to earlier days in the agenda.** Scroll up past today to see earlier days' events, loaded a month at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today ([#367]).
- **Create an event from shared or selected text.** Share text to Calendula, or select text in another app and pick Create event from the selection menu, and the new-event form opens with it filled in. A shared subject becomes the title, otherwise the first line does, and the rest goes into the description. The event starts at the next full hour with your default length; Calendula doesn't try to read a date out of the text. If Calendula was opened just for this, saving or closing the form takes you back to the app you came from ([#306]).
- **Tapping an event can open the edit form.** Settings → Views → Events → Tapping an event opens can now be set to Edit form instead of Details. Taps in the views and in search then go straight to the editor for events you can edit; read-only events, reminder notifications and widgets still open the details. The editor has Duplicate, Share and Delete in its menu, and going back with unsaved changes asks whether to save, discard or keep editing ([#178]).
- **A black theme for AMOLED screens.** Settings → Appearance → Theme now has Black next to System, Light and Dark. It's the dark theme with pure black backgrounds, which saves battery on AMOLED screens and makes event colours stand out more. Day cells and the hour grid stay a shade above black so the grid lines still show, and sheets and menus still stand out from the page. It works with Material You colours too. The home-screen widgets aren't affected ([#181]).
### Fixed
- **Event colours follow the theme you picked in Calendula.** With the app set to Dark on a phone in light mode, or the other way round, events took their colours from the phone's setting, so they could come out too pale or too dark. They now match the app's theme. Starting the app with a theme set in Calendula also no longer flashes the phone's light or dark background first ([#181]).
- **Events on the day the clocks change show at their real time.** In the day, 3-day and week views, events later on the day of a daylight saving change were drawn an hour off, so a 14:45 event showed at 15:45. They now sit at their own time. When the clocks go back, 02:00 to 03:00 happens twice: an event in that hour now stays in the right one when you move, resize or edit it, and the editor and event details add the zone, like CEST or CET, so you can tell the two apart ([#387]).
- **The agenda always starts today.** Coming from the month or day view, the agenda used to start at the day you had open there, so "Next 30 days" could show the 1st to the 30th of the month. It now opens on today with your chosen range. Backing out of a day you opened from the agenda still returns you to where you were ([#367]).
- **Multi-day events dim on the days already behind you.** With past events dimmed, an event running over several days stayed at full strength on its earlier days until it ended. Those days now dim in the agenda, month, week and day views ([#367]).
## [2.22.5] — 2026-10-07
### Fixed
- **Reopening the app the next morning shows the right day.** The month view used to mark yesterday as today and keep the day you picked the evening before selected, so you had to tap today to see its events. Every view now moves on to the new day at midnight and when you come back to the app. A view you browsed to another date stays where it is ([#373]).
## [2.22.4] — 2026-10-07
### Fixed
- **Edits and deletions show up straight away on every phone.** On some phones, Samsung among them, an edited, moved or deleted event stayed where it was until the app was restarted, so a second edit could act on an event that had already changed. Calendula now refreshes its views itself after every change, and again when you come back to the app ([#364]).
- **The home-screen widgets do less work per update.** The month widget loads the months around the one it shows instead of more than two years of events, and the agenda widget only its chosen range. This should help widgets that stayed on "loading" on slower phones ([#363]).
## [2.22.3] — 2026-10-02
### Fixed
- **The store listing is called "Calendula: Calendar" again,** with "Calendar" in each store language. Since 2.22.0 the Play listing comes from the repository, whose title was only "Calendula". The app is unchanged ([#361]).
## [2.22.2] — 2026-10-02
### Fixed
- **2.22 reaches Google Play.** The Dutch store listing used the locale code `nl`, which Play rejects, so the Play uploads of 2.22.0 and 2.22.1 failed. It now uses `nl-NL`, and CI checks every store locale against the codes Play accepts. The app is unchanged from 2.22.1 ([#359]).
## [2.22.1] — 2026-10-02
### Fixed
- **Tapping a link in an event's location opens it in the browser instead of a maps app.** Links are underlined, as in the description, and tapping the rest of the location still opens maps. A link with a capitalised `Https://`, or wrapped in `<…>` the way Outlook and Teams write them, opens correctly too. When no app can open a link, a short message says so instead of the tap doing nothing ([#357]).
## [2.22.0] — 2026-10-01
### Added
- **Day, Week, and Month all page with a swipeable pager that follows your finger** instead of cutting between dates ([#131], [#336]).
- **Pages can open the split agenda.** Swipe up from a full month to reveal the compact grid and month agenda; tap a day to filter it, then swipe down to return. The transition gives haptic feedback ([#111]).
- **A multi-day view shows a configurable number of days side by side,** with today in the first column. Set the day count in Settings ([#193]).
- **A "Visible hours" setting fits a chosen range of hours to the screen** when Day, Week, or the new multi-day view opens; earlier and later hours stay a scroll away ([#322]).
- **Timeline views mark events that run outside the visible hours** with a coloured edge at the top or bottom, and keep them tappable underneath it ([#188]).
- **Week numbers open their week.** Tap a week number in the month grid to open Week at that row; Back returns to Month ([#197]).
### Changed
- **Day, multi-day, and Week share one timeline and morph into each other** when you switch views, instead of each view rebuilding its own ([#184]).
- **The back gesture previews the view you're returning to** and springs back to the current one if you cancel the gesture ([#185]).
- **Split view opens with the month's agenda.** It starts at today in the current month; tapping a day filters the list, and the filter chip restores the full month. Swiping the list moves by day when filtered and by month otherwise ([#142]).
- **Today stands out in the month grid.** Its whole cell has a distinct background; tapping Today briefly expands that cell and nudges its neighbours aside ([#241]).
- **All-day reminders due today or tomorrow say so** instead of spelling out the date ([#308]).
- **Store listing in every app language.** Google Play and F-Droid show the description in all 16 languages the app ships, and each release publishes the Play listing from the repository ([#285]).
### Fixed
- **Titles and locations stop growing at 255 characters.** Some CalDAV servers (Open-Xchange among them) silently truncate longer values on sync; Calendula now caps them first and shows a counter once you're close ([#331]).
- **The "Calendar" launcher alias is translatable.** The "App name" setting's alternate label was hard-coded to English regardless of the app's language ([#251]).
- **Multi-day events stay above shorter events.** Month and Week bars, Split day lists, and Agenda now place multi-day events before one-day all-day events, then timed events; declined events remain last ([#291]).
- **Editing one occurrence of a coloured Google series saves again.** Saving with "Only this event" kept you on the edit screen without saving anything ([#338]).
## [2.21.0] — 2026-09-23
### Added
- **Setup makes sure you have a calendar to write to.** A fresh install could
finish onboarding and land on an empty grid with nothing saying why — either
because the device had no calendar at all, or because every calendar it had
was switched off. Setup now covers both: it offers to create a calendar on
this device or to add an account that syncs one, and it lists the calendars
that exist but are switched off so you can turn them back on ([#287]).
### Changed
- **The month grid gives its days more room.** Week numbers are set smaller and
their gutter is measured from the widest number it has to seat instead of
holding a fixed 40dp; event chips spend less of their width on padding; and
the labels in both are tracked more tightly, which is worth roughly a
character per chip ([#189], [#190], [#212], [#213]).
- **A month day shows as many events as it has height for.** The limit was a
flat three at every size, so a tall week row threw away space it could have
filled, while a cramped one drew a third chip its band could not hold and put
the "more" marker below the clip, where nothing showed the day held more at
all. It is measured per week row now, and a tablet or a landscape phone seats
more of them.
- **Split view no longer pads a short month out to six rows.** A five-row month
was handing the day pane a blank row and its gap — a sixth of the grid. The
grid now draws only the rows its month spans and the pane grows into the rest
([#162]).
### Fixed
- **Short events show their title in week and day view.** A block needed 20dp
of height before it drew any title at all, which at fit-the-whole-day put the
cutoff around the half hour. The padding now tapers as the block shrinks, so
a short event keeps its name ([#289]).
- **A fully zoomed-out timeline sits still.** Rounding its height up left it as
much as a pixel per hour taller than the viewport those hours are meant to
fill — some 24px of leftover scroll, enough to bounce off Android's
overscroll stretch ([#290]).
- **Backup & restore says what is going on instead of coming up blank.** It
built its rows straight from the calendar list, so loading, a provider error,
and a device with nothing to export all rendered as an empty page with no
buttons on it ([#304]).
- **An event with no title reads in your language.** It was labelled "(Ohne
Titel)" on every screen but the event's own, whatever language the phone was
set to: the German text was chosen before the translated one could be
([#321]).
- **A calendar the system gives no name reads in your language.** The same
cause, in the calendar lists and pickers — and because the calendar editor
starts from that name, saving such a calendar wrote the German text into it
for real ([#329]).
## [2.20.4] — 2026-09-20
### Fixed
- **The app no longer reopens the event you last came in on.** Opening an event
from a reminder or a home-screen widget made the app return to that same
event on every later launch — and show an error screen once the event had
been deleted. Android hands an app the launch it was started with again each
time it is rebuilt, and Calendula was acting on it every time instead of
once ([#309]).
- **"Today" works in the agenda again.** Scrolling a few days ahead and tapping
today did nothing, because the agenda is browsed by scrolling rather than by
moving its date, and the button only moved the date. It now brings the list
back to today, and it stays offered while the list is scrolled away — before,
it was hidden in exactly the situation it is for ([#305]).
- **Calendula is filed under "Calendar & Agenda" in the self-hosted repo.**
Browsing by category in an F-Droid client set to prefer that repo listed the
app under "Time" instead ([#294]).
## [2.20.3] — 2026-09-12
### Fixed
- **A custom agenda range crashed the app in French.** Choosing a custom range
for the agenda — or simply typing a number into that field — closed the app on
any phone set to French. The French translation of the "%d days" label was
missing the wording French uses for counts of two and above, and Android has
nothing to fall back on when that particular form is absent, so every value
except one day was affected ([#297], [#298]).
## [2.20.2] — 2026-09-12
### Fixed
- **Crash reports are filed in English.** Reporting a crash from a phone set to
another language prefilled the issue — title and body — in that language, so
a French crash arrived on Codeberg as a French issue ([#297], [#298]). The
text that is *posted* is now English for everyone; the dialog that asks you
to send it, and everything else you see in the app, is still translated.
## [2.20.1] — 2026-09-07
### Fixed
- **An empty calendar now explains itself.** "No calendars configured" offered
an "Open system calendar settings" button that opened nothing — every screen
wired it to the same reload that had just produced the empty state. It now
opens the system's account chooser, filtered to the providers that sync
calendars ([#239]).
- **Calendars that are all switched off no longer look like an empty month.**
When every calendar on the device is hidden at system level there is nothing
to draw, but the app said nothing at all: it only ever checked whether the
device had *no* calendars, so a device that had them and hid them fell
through to a blank grid with no hint. That case now says so, and points at
Settings → Calendars — the one screen that can switch a calendar back on. The
navigation drawer's calendar list drew the same conflation and is fixed with
it ([#239]).
## [2.20.0] — 2026-09-07
### Added
- **Long-press to copy.** Holding down the title, the location or the
description on an event puts that whole field on the clipboard. Tapping still
does what it always did — the location opens maps, a link in the description
opens — and the times, calendar, repeat rule and guests stay uncopyable. Below
Android 13 a short confirmation appears; from Android 13 on the system shows
its own clipboard chip ([#195]).
- **The open-source projects Calendula ships** are now listed under Settings →
About, each with its copyright holder and licence, and each row tapping
through to that project's source ([#281]).
### Changed
- Event titles no longer end in an ellipsis. The "…" took the width of a couple
of characters and told you nothing you couldn't already see, so the title now
simply runs to the edge of its chip in the month, week and day views — a few
more letters per event, which is often the difference between two events you
can tell apart and two you can't. Right-to-left languages keep the ellipsis,
where clipping would cut the beginning of a title rather than its end
([#164]).
- Month-view events now show their start time before the title, where the chip
is wide enough to keep the title readable — in landscape, on a foldable and on
a tablet, and never at the cost of a narrow phone column. All-day events show
no time, and neither does a bar carried in from the previous week, whose start
is not in that row. Event times across the month, week and day views are also
set in a regular weight against the title's, so a time reads as a time rather
than as part of the name next to it ([#219]).
- **The hour lines in week and day view are now an hour grid.** The setting
that drew a faint separator line at each hour now seats every hour in its own
rounded cell, so an hour boundary reads as the seam between two surfaces
rather than a line drawn across one — the same negative space that separates
the month grid's days and the week's columns. An event that starts on the hour
fills its cell instead of overhanging the seam, and one that runs past
midnight still meets the edge of its column ([#113]).
- **An invitation you haven't answered now reads as an outline.** An event still
waiting on your reply is drawn with the calendar's colour on its border and
its title and nothing behind it, so it stands apart from the ones you have
accepted — on the month chips, the all-day bars and the week and day blocks
alike. Declined events keep their filled, struck-through chip and now sort
last in the month view as well, so a day with more events than fit drops the
ones you said no to first ([#230]).
- The calendar titles now shorten instead of being cut off. When the full month
name doesn't fit the top bar, the month and week views fall back to its
three-letter form and the day view drops the weekday, rather than trailing off
mid-word at large font sizes. The view switcher, the title and the agenda's
range bar also line up with the grid underneath them ([#165]).
- The jump-to-today button in the toolbar now shows today's date. It drew a
generic calendar icon, which told you nothing you didn't already know from
tapping it; it now carries the current day number in an outlined box, so the
bar says what day it is as well as taking you there. It rolls over at midnight
on its own ([#220]).
- The quick-switch pill disappears when there is nothing left to switch between,
and you can now turn calendar views off all the way down to a single one — the
setting used to hold two open purely to keep the pill fed ([#150]).
### Fixed
- **A long location no longer runs off the edge of its field.** The location on
the edit screen sat on one line, so a full postal address or a long meeting
link scrolled sideways out of sight as you typed it. It now wraps and the card
grows to fit, with the pin and the contacts button staying level with the first
line at any font size. A multi-line address you paste in is joined with commas
— the way one picked from your contacts always has been — instead of running
together into a single word ([#273]).
- **The event form capitalizes sentences again.** The title, description and
guest fields had stopped starting a sentence with a capital letter; they
follow the keyboard's usual behaviour once more. The location is deliberately
left alone, since it is as often a URL as an address ([#146]).
- **A block you pick up no longer loses its second line.** Dragging an event
whose time or title wrapped to two lines clipped the floating copy to one line
for as long as you held it, and it snapped back on drop. The copy now measures
its text exactly as the block does ([#267]).
## [2.19.4] — 2026-09-01
### Fixed
@@ -1802,8 +1560,6 @@ automatically, with zero telemetry and no internet permission.
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
[#164]: https://codeberg.org/jlmakiola/calendula/issues/164
[#165]: https://codeberg.org/jlmakiola/calendula/issues/165
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
@@ -1813,64 +1569,5 @@ automatically, with zero telemetry and no internet permission.
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
[#219]: https://codeberg.org/jlmakiola/calendula/issues/219
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253
[#273]: https://codeberg.org/jlmakiola/calendula/issues/273
[#113]: https://codeberg.org/jlmakiola/calendula/issues/113
[#220]: https://codeberg.org/jlmakiola/calendula/issues/220
[#195]: https://codeberg.org/jlmakiola/calendula/issues/195
[#281]: https://codeberg.org/jlmakiola/calendula/issues/281
[#230]: https://codeberg.org/jlmakiola/calendula/issues/230
[#150]: https://codeberg.org/jlmakiola/calendula/issues/150
[#146]: https://codeberg.org/jlmakiola/calendula/issues/146
[#267]: https://codeberg.org/jlmakiola/calendula/issues/267
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309
[#305]: https://codeberg.org/jlmakiola/calendula/issues/305
[#294]: https://codeberg.org/jlmakiola/calendula/issues/294
[#287]: https://codeberg.org/jlmakiola/calendula/issues/287
[#189]: https://codeberg.org/jlmakiola/calendula/issues/189
[#190]: https://codeberg.org/jlmakiola/calendula/issues/190
[#212]: https://codeberg.org/jlmakiola/calendula/issues/212
[#213]: https://codeberg.org/jlmakiola/calendula/issues/213
[#162]: https://codeberg.org/jlmakiola/calendula/issues/162
[#289]: https://codeberg.org/jlmakiola/calendula/issues/289
[#290]: https://codeberg.org/jlmakiola/calendula/issues/290
[#304]: https://codeberg.org/jlmakiola/calendula/issues/304
[#321]: https://codeberg.org/jlmakiola/calendula/issues/321
[#329]: https://codeberg.org/jlmakiola/calendula/issues/329
[#285]: https://codeberg.org/jlmakiola/calendula/issues/285
[#338]: https://codeberg.org/jlmakiola/calendula/issues/338
[#111]: https://codeberg.org/jlmakiola/calendula/issues/111
[#131]: https://codeberg.org/jlmakiola/calendula/issues/131
[#142]: https://codeberg.org/jlmakiola/calendula/issues/142
[#184]: https://codeberg.org/jlmakiola/calendula/issues/184
[#185]: https://codeberg.org/jlmakiola/calendula/issues/185
[#188]: https://codeberg.org/jlmakiola/calendula/issues/188
[#193]: https://codeberg.org/jlmakiola/calendula/issues/193
[#197]: https://codeberg.org/jlmakiola/calendula/issues/197
[#241]: https://codeberg.org/jlmakiola/calendula/issues/241
[#251]: https://codeberg.org/jlmakiola/calendula/issues/251
[#291]: https://codeberg.org/jlmakiola/calendula/issues/291
[#308]: https://codeberg.org/jlmakiola/calendula/issues/308
[#322]: https://codeberg.org/jlmakiola/calendula/issues/322
[#331]: https://codeberg.org/jlmakiola/calendula/issues/331
[#336]: https://codeberg.org/jlmakiola/calendula/issues/336
[#357]: https://codeberg.org/jlmakiola/calendula/issues/357
[#359]: https://codeberg.org/jlmakiola/calendula/issues/359
[#361]: https://codeberg.org/jlmakiola/calendula/issues/361
[#363]: https://codeberg.org/jlmakiola/calendula/issues/363
[#364]: https://codeberg.org/jlmakiola/calendula/issues/364
[#367]: https://codeberg.org/jlmakiola/calendula/issues/367
[#373]: https://codeberg.org/jlmakiola/calendula/issues/373
[#141]: https://codeberg.org/jlmakiola/calendula/issues/141
[#153]: https://codeberg.org/jlmakiola/calendula/issues/153
[#339]: https://codeberg.org/jlmakiola/calendula/issues/339
[#387]: https://codeberg.org/jlmakiola/calendula/issues/387
[#271]: https://codeberg.org/jlmakiola/calendula/issues/271
[#181]: https://codeberg.org/jlmakiola/calendula/issues/181
[#306]: https://codeberg.org/jlmakiola/calendula/issues/306
[#178]: https://codeberg.org/jlmakiola/calendula/issues/178
+29 -29
View File
@@ -1,13 +1,13 @@
# Contributing to Calendula
Calendula is a Material 3 Expressive calendar app that lives strictly on top of
Android's `CalendarContract`, with no app database, sync stack or network access.
That constraint shapes most review comments, so skim
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) before you write code. This file
covers the practical side.
Android's `CalendarContract` — no app database, no sync stack, no network access.
That constraint shapes most review comments, so
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) is worth skimming before you write
code. This file is the practical how.
[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home for
issues, pull requests and releases. The self-hosted Gitea instance referenced in the
**[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home** —
issues, pull requests, releases. The self-hosted Gitea instance referenced in the
release docs is build infrastructure only; there is nothing to contribute there.
Be decent to the people you meet in the tracker.
@@ -18,20 +18,20 @@ Be decent to the people you meet in the tracker.
| Add a feature | **Open an issue first** and wait for a go-ahead |
| Fix a bug | Open an issue, then a pull request |
| Fix a typo, a comment, or docs | Just open the pull request |
| Add or fix a translation | Use [Weblate](#translations), not a pull request |
| Add or fix a translation | **Don't** — [use Weblate](#translations) |
Features get an opinion before they get code: whether Calendula should do a
thing at all is the one decision a patch can't make. A feature PR that arrives
without a discussed issue may be closed unmerged even when the code is good, so
without a discussed issue may be closed unmerged even when the code is good —
please don't spend a weekend on one first.
Bugs are more straightforward, but still start with an issue: it's what carries
the milestone and gives the changelog something to link.
Issue templates cover bug, crash, feature and question. For a crash, let the app
do the work: *Settings → Report a problem*, or the prompt shown after a crash,
do the work — **Settings → Report a problem**, or the prompt shown after a crash,
captures the stack trace and prefills the form. The report contains app, Android
and device versions plus the trace, and no calendar content or personal data.
and device versions plus the trace; no calendar content, no personal data.
## Which branch to target
@@ -45,20 +45,20 @@ Once your issue has a milestone, that milestone names your branch:
| `2.18.0` | `release/v2.18.0` |
Every milestone has a matching branch. If it's somehow missing, target `main` and
mention it in the PR, and it will be retargeted. Don't pick an older release branch:
mention it in the PR — it will be retargeted. Don't pick an older release branch:
they're kept after shipping, so the newest one isn't necessarily yours.
## Translations
**Never edit a `values-*/strings.xml` file in a pull request**, German included.
**Never edit a `values-*/strings.xml` file in a pull request** — German included.
Translations are owned by a self-hosted Weblate that writes to this repository
directly, and a hand-edit is overwritten on the next sync.
[Translate Calendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)
→ **[Translate Calendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
picks it up and offers it to translators. Partial translations are expected and
fine; missing keys are informational. Stale and orphaned keys are not, so run
fine — missing keys are informational. Stale and orphaned keys are not, so run
```sh
python3 scripts/check_translations.py
@@ -67,7 +67,7 @@ python3 scripts/check_translations.py
before pushing. It reports those more clearly than lint's `MissingTranslation`
does.
## Build and test
## Build & test
```sh
git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git
@@ -77,11 +77,11 @@ The `floret-kit` submodule is a composite build compiled from source. An
existing clone needs `git submodule update --init --recursive`, or nothing
resolves.
- **JDK 17**, not newer: the Android Gradle Plugin requires exactly 17. Set
- **JDK 17** — not newer; the Android Gradle Plugin requires exactly 17. Set
`JAVA_HOME` if your default differs.
- **Android SDK**: platform 37 (`compileSdk`) and build-tools 36.0.0, located
- **Android SDK** — platform 37 (`compileSdk`) and build-tools 36.0.0, located
via `ANDROID_HOME` or a gitignored `local.properties` with `sdk.dir`. If you
go the `local.properties` route, the included build needs its own copy at
go the `local.properties` route the included build needs its own copy at
`floret-kit/local.properties`; `ANDROID_HOME` covers both at once and is the
easier path.
@@ -109,21 +109,21 @@ These are the ones that turn into review comments.
1. **No network.** Calendula holds no `INTERNET` permission, and that's a
feature rather than an oversight. Anything that would need one is a product
decision before it's a patch. The crash reporter opens a prefilled web issue
on purpose instead of posting anything itself.
decision before it's a patch — the crash reporter deliberately opens a
prefilled web issue instead of posting anything itself.
2. **The provider is the only database.** No Room, no cache, no local mirror of
events. `CalendarContract` is the single source of truth, which is also why
externally synced changes work for free.
3. **Don't patch UI state after a write.** A `ContentObserver` re-queries and
views recompose from fresh provider state. Hand-patching a list after saving
appears to work, then drifts away from what the provider stored.
appears to work, then quietly diverges from what the provider actually stored.
4. **`domain/` has no Android imports.** Models, validation, recurrence
rendering, conflict snapshots and the `.ics` codec stay pure Kotlin so they
remain JVM-testable.
5. **Tests run on the JVM.** JUnit 5 + Truth + Turbine. The seams exist for you:
fake the data source (`FakeCalendarDataSource`), and feed mappers plain maps
through `ColumnReader` instead of cursors. Instrumented tests are a last
resort.
resort, not a default.
6. **Read before touching the subtle pipelines.** Recurring writes (UNTIL vs
DURATION, exception URIs, series splits), save-conflict detection and reminder
delivery (post-before-mark) follow provider-driven rules that are documented
@@ -138,14 +138,14 @@ These are the ones that turn into review comments.
## UI conventions
Material 3 Expressive throughout, built from the system's own tokens and
components: colour-scheme tokens rather than hardcoded colours, `ListItem` for
components — colour-scheme tokens rather than hardcoded colours, `ListItem` for
settings rows.
**Selection pickers are full-screen.** Every browse-style "choose one" surface
uses floret-kit's `FullScreenPicker` / `OptionPicker`; one that needs a commit or
extra action passes it through the picker's `actions` slot. The exception is the
recurring-scope chooser (*this / this and following / all*), which stays a
compact dialog, because a two- or three-option decision reads better as a popup than as
compact dialog — a two- or three-option decision reads better as a popup than as
a nearly empty screen. `AlertDialog` is for plain confirmations only, and radio-
or text-list dialogs aren't used at all.
@@ -154,7 +154,7 @@ Shared UI machinery lives in the `floret-kit` submodule and has
changing it means a pull request against that repository plus a submodule bump
here.
## Commits and pull requests
## Commits & pull requests
Conventional commits, scoped to the area you touched:
@@ -165,13 +165,13 @@ docs(architecture): record what the second review pass changed
```
Types in use: `feat` `fix` `docs` `refactor` `style` `chore` `ci` `build`
`revert`. Reference the issue in the subject or the body. Keep commits small.
Small commits revert cleanly, which matters more here than a tidy history.
`revert`. Reference the issue in the subject or the body. Keep commits small —
small commits revert cleanly, which matters more here than a tidy history.
If your change is user-visible, add an entry under `## [Unreleased]` in
[`CHANGELOG.md`](CHANGELOG.md). Match the surrounding voice: entries describe
what changed *for the person using the app*, and why. Code changes don't go
there. Link the issue and add its reference at the bottom of the file. It may get
what changed *for the person using the app*, and why, not what changed in the
code. Link the issue and add its reference at the bottom of the file. It may get
reworded when the release is cut, so don't agonise over it.
Please don't commit planning or design documents. Code, tests, architecture notes
+100 -116
View File
@@ -4,8 +4,8 @@
<h1>Calendula</h1>
<p><strong>A Material 3 Expressive calendar for Android.</strong><br>
It works on top of the system calendar and has no network access.</p>
<p><strong>A modern Material 3 Expressive calendar for Android.</strong><br>
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
<p>
<a href="https://codeberg.org/jlmakiola/calendula/actions"><img src="https://codeberg.org/jlmakiola/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
@@ -26,89 +26,84 @@ It works on top of the system calendar and has no network access.</p>
</p>
<p>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="13%" alt="Week view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="13%" alt="Month view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-agenda.png" width="13%" alt="Agenda view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="13%" alt="Event detail">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-recurring.png" width="13%" alt="Editing a recurring event">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-calendars.png" width="13%" alt="Calendars from your accounts">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/07-dark.png" width="13%" alt="Dark theme">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="16%" alt="Week view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="16%" alt="Month view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-day.png" width="16%" alt="Day view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="16%" alt="Event detail">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-agenda.png" width="16%" alt="Agenda view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="16%" alt="Reminder onboarding">
</p>
</div>
Calendula is named after the flower. Both its name and the word *calendar*
come from the Latin *kalendae*, the first day of the month.
Calendula is named after the flower whose name — like the word *calendar* —
comes from the Latin *kalendae*, the first day of the month. It lives
entirely on top of Android's `CalendarContract`: any calendar synced to your
device (CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply
appears, and everything you create or edit syncs back the same way. No own
database, no sync stack reinvented.
The app works directly on Android's `CalendarContract`. Any calendar synced to
your device (CalDAV via DAVx5, Google, local, WebCal subscriptions) shows up in
Calendula, and whatever you create or edit syncs back the same way. Calendula
has no database or sync code of its own.
## ✨ Features
## Features
**Calendar**
### Calendar
- Month, week, and day views with a one-tap view switcher
- Full event details — attendees and their responses, reminders, recurrence
(humanized), availability, visibility, foreign time zones
- Per-calendar visibility toggle, grouped by account
- Month, week and day views, with a switcher in the top bar
- Event details with attendees and their responses, reminders, recurrence in
plain words, availability, visibility and foreign time zones
- Calendars can be shown or hidden one by one, grouped by account
- Your contacts' birthdays, anniversaries and other dates, if you turn it on.
Contacts are read on the device and never changed.
**Editing**
### Editing
- Create, edit, and delete events — including recurring events with scoped
writes: *only this event*, *this and all following*, or *the whole series*
- Recurrence picker with one-tap presets and custom rules (interval, weekday
toggles, end conditions); rules it can't express are preserved verbatim
- Conflict-safe saves: if an event changed elsewhere while you were editing,
Calendula asks instead of silently overwriting
- Read-only calendars (WebCal, birthdays) are detected and respected
- Create, edit and delete events. For a recurring event you choose whether a
change applies to *only this event*, *this and all following*, or *the whole
series*.
- The repeat picker has presets and custom rules (interval, weekdays, end
condition). Rules it can't show are kept as they are.
- If an event changed elsewhere while you were editing it, Calendula asks
before saving over it.
- Read-only calendars such as WebCal subscriptions and birthdays are detected
and can't be edited by accident.
**Reminders**
### Reminders
- Event reminders delivered by Calendula itself as notifications —
essential when it's your only calendar app, since Android delegates
reminder delivery to calendar apps
- Tap a reminder to land on the event
- Calendula shows event reminders as notifications itself. Android leaves
reminder delivery to calendar apps, so if Calendula is your only one, this is
where your reminders come from.
- Tapping a reminder opens the event.
**Design & privacy**
### Design and privacy
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
expressive motion and shapes, light/dark theme
- English and German UI plus community translations (Spanish, French, Italian,
Polish, and more in progress), per-app language setting — and [open to more
languages](#-translations)
- **Zero telemetry, zero analytics, no internet permission** — your data
never leaves the device
- Material 3 Expressive throughout, with dynamic color on Android 12+, light
and dark themes, and expressive motion and shapes
- Available in 16 languages, with a per-app language setting.
[Help translate](#translations).
- No telemetry, no analytics and no internet permission. Your data stays on
the device.
## 📦 Install
## Install
All channels install the same app:
Pick whichever channel you already use — they all install the same app:
| Channel | Updates | Notes |
| --- | --- | --- |
| [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup |
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; add the repo once |
| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automatic with Obtainium |
| [Google Play](#google-play) | Per release | Signed with Google's key, see below |
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; needs the repo added once |
| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automated by Obtainium |
| [Google Play](#google-play) | Per release | Signed with Google's key — see below |
| [Build from source](#build-from-source) | Whenever you build | Full control |
### F-Droid (recommended)
Calendula is in the official [F-Droid](https://f-droid.org) repository. Search
for Calendula in any F-Droid client, or
Calendula is on the **official [F-Droid](https://f-droid.org) repository** —
just search for **Calendula** in any F-Droid client, or
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
F-Droid builds from source on its own schedule, so a new version usually shows
up there a few days after release.
F-Droid rebuilds from source on its own schedule, so a new version usually
shows up there a few days after release.
### Self-hosted F-Droid repo (fastest updates)
The release pipeline builds, signs and publishes every release to a
self-hosted F-Droid repository, so new versions land there first. Add it once
Every release is built, signed, and published to a self-hosted F-Droid
repository as part of the release pipeline, so it lands there first. Add it once
and your F-Droid client handles updates from then on:
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
@@ -122,87 +117,76 @@ and your F-Droid client handles updates from then on:
fingerprint (SHA-256):
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
2. Refresh, search for Calendula and install it.
2. Refresh, search for **Calendula**, install.
### Codeberg release / Obtainium
Every release is also on
[Codeberg](https://codeberg.org/jlmakiola/calendula/releases) with the signed
APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum. Download it and install
it directly.
If you'd rather not use F-Droid at all, every release is also published on
**[Codeberg](https://codeberg.org/jlmakiola/calendula/releases)** with the
signed APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum attached — download
and install it directly.
For automatic updates from Codeberg, use
[Obtainium](https://github.com/ImranR98/Obtainium). On your phone,
[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula),
or add it by hand: *Add App*, paste `https://codeberg.org/jlmakiola/calendula`,
then *Add*. Obtainium then watches the releases and tells you when a new one
is out. Betas of upcoming versions are published there too, as pre-releases; to
test them, switch on *Include prereleases* for Calendula in Obtainium.
For automatic updates from that channel, use
**[Obtainium](https://github.com/ImranR98/Obtainium)** — on the phone,
**[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula)**,
or do it by hand: *Add App* → paste `https://codeberg.org/jlmakiola/calendula`
→ *Add*. Either way, Obtainium tracks the releases and prompts you when a new
one appears.
### Google Play
Calendula is on
[Google Play](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula).
Calendula is live on Google Play:
**[play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula)**.
Play builds are signed with Google's key rather than mine, so switching between
Play and any other channel means uninstalling first. You lose the app settings.
Your events live in the system calendar and aren't affected.
Play and any other channel requires an uninstall (and with it, a fresh start for
app settings — your events live in the system calendar and are unaffected).
### Build from source
It's a plain Gradle build with no proprietary dependencies. See
[docs/BUILDING.md](docs/BUILDING.md), and note the `floret-kit` submodule.
The build is a plain Gradle build with no proprietary dependencies — see
**[docs/BUILDING.md](docs/BUILDING.md)** (note the `floret-kit` submodule).
<sub>Official F-Droid, the self-hosted repo and the Codeberg releases share the
same signing key, so you can switch between them without reinstalling. Google
Play is the exception (see above).</sub>
<sub>Official F-Droid, the self-hosted repo, and the Codeberg releases all share
the same signing key, so you can switch freely between them without
reinstalling. Google Play is the exception — see above.</sub>
## Documentation
## 📚 Documentation
- [Contributing](CONTRIBUTING.md): how to report bugs, propose features and send patches
- [Building from source](docs/BUILDING.md): requirements and Gradle tasks
- [Architecture](docs/ARCHITECTURE.md): the layers and the main pipelines
- [Milestones](https://codeberg.org/jlmakiola/calendula/milestones): what has shipped and what's next
- [Privacy policy](docs/PRIVACY.md): the source of the page at
[jeanlucmakiola.de/calendula/privacy](https://jeanlucmakiola.de/calendula/privacy)
- **[Contributing](CONTRIBUTING.md)** — how to report, propose, and patch
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
- **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next
## Contributing
## 🤝 Contributing
Bug reports, ideas and patches are welcome on
[Codeberg](https://codeberg.org/jlmakiola/calendula/issues).
Bug reports, ideas, and patches are all welcome on
**[Codeberg](https://codeberg.org/jlmakiola/calendula/issues)**.
Start with an issue. A feature gets a yes or no before anyone writes code, and
features and bugs are assigned a milestone whose `release/vX.Y.Z` branch your
pull request targets. Typo and docs fixes can go straight to a pull request.
Translations go through [Weblate](#translations), not pull requests.
The short version: **start with an issue.** Features get a yes-or-no before they
get code, and both features and bugs are assigned a milestone whose
`release/vX.Y.Z` branch your pull request then targets. Typo and docs fixes can
skip straight to a pull request. Translations don't go through pull requests at
all — [Weblate owns them](#-translations).
Read [CONTRIBUTING.md](CONTRIBUTING.md) before writing code. It covers the
workflow, the build (including the `floret-kit` submodule) and the
architectural rules changes are reviewed against.
Read **[CONTRIBUTING.md](CONTRIBUTING.md)** before writing code: it covers the
workflow, the build (note the `floret-kit` submodule), and the architectural
rules a change is reviewed against.
## Translations
## 🌍 Translations
Calendula is written in English. Translators on Weblate have added Arabic,
Belarusian, Chinese (Simplified), Czech, French, German, Hungarian, Italian,
Japanese, Polish, Portuguese (Portugal and Brazil), Russian, Slovak and Spanish,
some of them partly. Untranslated strings fall back to English, so a partial
translation is fine. Translations are managed on a self-hosted Weblate:
Calendula ships in English and German, with community translations in Arabic,
Chinese, French, Italian, Polish, Portuguese, Russian, and Spanish at varying
degrees of completeness — partial is fine, untranslated strings simply fall back
to English. You're warmly invited to add or finish your language. Translations
are managed on a self-hosted **Weblate**:
[Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)
**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
You don't need to code. Register on the Weblate server, pick a language (or
request one) and translate in your browser. The app links here from the top of
*Settings → App language*.
No coding needed — register on the Weblate server, pick (or request) a language,
and translate the strings in your browser. You can also reach this link in the
app from the top of **Settings → App language**.
## Privacy
## 📜 License
Calendula has no analytics, tracking, ads, crash-reporting SDK or third-party
services. It doesn't have the internet permission, so it can't send anything
anywhere. Your events stay in Android's own calendar store.
[Privacy policy](https://jeanlucmakiola.de/calendula/privacy), written in
[`docs/PRIVACY.md`](docs/PRIVACY.md), which is what that page renders.
## License
[MIT](LICENSE), Jean-Luc Makiola, 2026
[MIT](LICENSE) — Jean-Luc Makiola, 2026
+10 -21
View File
@@ -26,14 +26,10 @@ android {
// 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. A versionName of X.Y.Z-beta.N pushed to a release/*
// branch instead cuts a Codeberg-only pre-release (beta.yaml).
// versionCode is derived from versionName by scripts/version_info.sh
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md.
versionCode = 2230004
versionName = "2.23.0-beta.4"
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21904
versionName = "2.19.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -117,16 +113,12 @@ android {
lint {
// Community translations are expected to be partial — a missing string
// falls back to the English base at runtime — so don't fail the build on
// it. A translated <plurals> may likewise skip a CLDR quantity form its
// locale defines (e.g. Arabic "zero"): Android falls back to "other" for
// any form it cannot find, so MissingQuantity is informational too.
// What a translation must NOT skip is "other" itself — nothing falls back
// for that one, not even the base locale, so it throws
// Resources$NotFoundException at runtime (Codeberg #297/#298).
// MissingQuantity doesn't tell the two cases apart, so that invariant is
// enforced by scripts/check_translations.py instead, with a clearer
// translator-facing message. Stale/extra keys (ExtraTranslation) stay
// fatal.
// it. Likewise a translated <plurals> may not fill every CLDR quantity
// form its locale defines (e.g. Arabic needs "zero"); the missing form
// falls back to "other" at runtime, so MissingQuantity is informational
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
// check_translations.py guards the same invariants with clearer,
// translator-facing messages.
informational += listOf("MissingTranslation", "MissingQuantity")
}
@@ -175,10 +167,7 @@ dependencies {
implementation("de.jeanlucmakiola.floret:core-time")
implementation("de.jeanlucmakiola.floret:core-locale")
implementation("de.jeanlucmakiola.floret:core-crash")
implementation("de.jeanlucmakiola.floret:core-di")
implementation("de.jeanlucmakiola.floret:core-prefs")
implementation("de.jeanlucmakiola.floret:core-reminders")
implementation("de.jeanlucmakiola.floret:core-android")
implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components")
implementation(libs.kotlinx.coroutines.core)
@@ -1,91 +0,0 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.Manifest
import android.content.ContentUris
import android.content.ContentValues
import android.provider.CalendarContract
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.toEditForm
import kotlinx.datetime.TimeZone
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Moving a synced series keeps its deleted and edited occurrences, although the
* moved row has no `_sync_id` for exception rows to attach to.
*/
@RunWith(AndroidJUnit4::class)
class MoveEventExceptionsTest {
@get:Rule
val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(
Manifest.permission.READ_CALENDAR,
Manifest.permission.WRITE_CALENDAR,
)
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val resolver = context.contentResolver
private val ds = AndroidCalendarDataSource(context)
private val zone = TimeZone.currentSystemDefault()
private val day = 86_400_000L
private val start = 1_893_492_000_000L // 2030-01-01T10:00Z
private val hour = 3_600_000L
/** "day offset:title" of every instance on [calendarId]. */
private fun occurrences(calendarId: Long): List<String> =
ds.instances(start - day, start + 10 * day)
.filter { it.calendarId == calendarId }
.map { "${(it.start.toEpochMilliseconds() - start) / day}:${it.title}" }
.sorted()
@Test
fun movedSeriesKeepsCancelledAndModifiedOccurrences() {
val a = ds.createLocalCalendar("move-test-a", 0xFF336699.toInt(), null)
val b = ds.createLocalCalendar("move-test-b", 0xFF993366.toInt(), null)
try {
val src = insertSyncedSeries(a)
ds.deleteOccurrence(src, start + 2 * day)
val occurrence = ds.eventDetail(src, 540)!!.toEditForm(start + 3 * day, start + 3 * day + hour, zone)
ds.updateOccurrence(src, start + 3 * day, occurrence, occurrence.copy(title = "edited"), 540)
val expected = listOf("0:series", "1:series", "3:edited", "4:series")
assertThat(occurrences(a)).isEqualTo(expected)
val form = ds.eventDetail(src, 540)!!.toEditForm(start, start + hour, zone)
ds.moveEvent(src, b, form, form, 540)
assertThat(occurrences(a)).isEmpty()
assertThat(occurrences(b)).isEqualTo(expected)
} finally {
ds.deleteCalendar(a)
ds.deleteCalendar(b)
}
}
/** A daily series carrying a `_sync_id`, so its exceptions are real exception rows. */
private fun insertSyncedSeries(calendarId: Long): Long {
val accountName = resolver.query(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
arrayOf(CalendarContract.Calendars.ACCOUNT_NAME), null, null, null,
)!!.use { it.moveToFirst(); it.getString(0) }
val uri = CalendarContract.Events.CONTENT_URI.buildUpon()
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, accountName)
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
.build()
val values = ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, calendarId)
put(CalendarContract.Events.TITLE, "series")
put(CalendarContract.Events.DTSTART, start)
put(CalendarContract.Events.DURATION, "P3600S")
put(CalendarContract.Events.RRULE, "FREQ=DAILY;COUNT=5")
put(CalendarContract.Events.EVENT_TIMEZONE, "UTC")
put(CalendarContract.Events._SYNC_ID, "move-test-series")
}
return ContentUris.parseId(resolver.insert(uri, values)!!)
}
}
@@ -1,101 +0,0 @@
package de.jeanlucmakiola.calendula.ui.detail
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
/**
* The platform phone matcher on real text. It reads the region off the SIM,
* so these stick to international numbers and to text that must never match
* anywhere — domestic formats depend on the device they run on.
*/
@RunWith(AndroidJUnit4::class)
class PhoneLinksPlatformTest {
private fun numbers(text: String) = findPhoneLinks(text).map { it.number }
@Test
fun internationalNumbers_areFound() {
assertThat(numbers("Call +49 30 1234567 or +1 646-558-8656"))
.containsExactly("+49301234567", "+16465588656")
.inOrder()
}
@Test
fun trunkZero_isDroppedWithOrWithoutSpace() {
assertThat(numbers("+49 (0)151 2345678")).containsExactly("+491512345678")
assertThat(numbers("+49 (0) 151 2345678")).containsExactly("+491512345678")
}
@Test
fun neighbouringNumbers_doNotJoinTheNumber() {
assertThat(numbers("Start 10:00 +49 151 2345678")).containsExactly("+491512345678")
assertThat(numbers("Call +49 151 2345678 2 times")).containsExactly("+491512345678")
}
@Test
fun conferenceDialIn_keepsMeetingId() {
assertThat(numbers("One tap mobile: +16465588656,,85012345678# US"))
.containsExactly("+16465588656,,85012345678#")
}
@Test
fun meetingIds_areNotNumbers() {
assertThat(numbers("Meeting ID: 850 1234 5678")).isEmpty()
assertThat(numbers("Conference ID: 123 456 789#")).isEmpty()
}
@Test
fun datesAndTimes_areNotNumbers() {
assertThat(numbers("2026-10-14 10:00")).isEmpty()
assertThat(numbers("Am 14.10.2026 10:00 Uhr")).isEmpty()
assertThat(numbers("14.10.26 - 16.10.26")).isEmpty()
assertThat(numbers("10:00 - 11:30")).isEmpty()
assertThat(numbers("Schuljahr 2025/2026, Season 2025-2026")).isEmpty()
assertThat(numbers("1.10.2026 - 3.10.2026")).isEmpty()
}
@Test
fun amounts_areNotNumbers() {
assertThat(numbers("Budget 1.250.000 EUR, 1 250 000 EUR")).isEmpty()
}
@Test
fun isbn_isNotANumber() {
assertThat(numbers("ISBN 978-3-16-148410-0")).isEmpty()
}
@Test
fun extension_becomesAPause() {
assertThat(numbers("+1 212 555 1234 ext. 567")).containsExactly("+12125551234,567")
}
@Test
fun webexAccessCode_isNotANumber() {
assertThat(numbers("Meeting number (access code): 2732 123 4567")).isEmpty()
}
@Test
fun commaSeparatedNumbers_stayApart() {
assertThat(numbers("+49 30 1234567,+49 151 2345678"))
.containsExactly("+49301234567", "+491512345678")
.inOrder()
}
@Test
fun dottedTimes_areNotNumbers() {
assertThat(numbers("Beginn 9.30 – 12.00 Uhr")).isEmpty()
assertThat(numbers("10.00-11.30")).isEmpty()
}
@Test
fun schemelessUrlDigits_areNotNumbers() {
assertThat(numbers("zoom.us/j/85012345678")).isEmpty()
}
@Test
fun webLinkDigits_areNotNumbers() {
assertThat(numbers("https://zoom.us/j/85012345678?pwd=1")).isEmpty()
}
}
-13
View File
@@ -151,19 +151,6 @@
<data android:mimeType="application/ics" />
</intent-filter>
<!-- Text shared or selected in another app opens a prefilled new
event (MainActivity.sharedTextExtrasOrNull, issue #306). -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:label="@string/process_text_create_event">
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
to create a new event, the way the AOSP calendar accepts it:
ACTION_INSERT on the events *dir* mime type, carrying the new
@@ -32,15 +32,13 @@ class CalendulaApp : Application() {
// respecting, on-device; the user submits the report by hand). The
// capture/loop-detection/report machinery lives in floret-kit's
// core-crash; only the app label + issue-tracker URLs are app-specific.
// The prefilled issue's own title and body are the kit's, in English:
// they are posted to the tracker, so they don't follow the device
// language the way the dialog around them does.
CrashReporter.install(
this,
CrashConfig(
appLabel = getString(R.string.app_name),
newIssueUrl = getString(R.string.report_issue_url),
chooseIssueUrl = getString(R.string.report_issue_choose_url),
issueTitle = getString(R.string.crash_report_issue_title),
),
)
reconcileAutoBackup()
@@ -25,22 +25,13 @@ import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.isDark
import de.jeanlucmakiola.floret.prefs.isPureBlack
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
import de.jeanlucmakiola.calendula.domain.sharedTextOrNull
import de.jeanlucmakiola.calendula.ui.ExternalRequests
import de.jeanlucmakiola.calendula.ui.InsertRequest
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
@@ -57,43 +48,47 @@ import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.submitCrashReport
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
import de.jeanlucmakiola.calendula.ui.theme.windowBackgroundRes
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
/** A prefilled create form from an external launch, with the source it came from. */
private data class InsertRequest(val form: EventForm, val source: ImportSource)
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject lateinit var calendarRepository: CalendarRepository
@Inject lateinit var settingsPrefs: SettingsPrefs
// Which of light/dark the system bars are drawn for. The styles installed
// in onCreate read this field live, so androidx's config-change replay
// picks up the in-app override instead of the night resource qualifier.
private var systemBarsDark = false
private val requests = ExternalRequests(
onExternalFormClosed = {
insertFormOpen = false
if (launchedForInsert) finish()
},
)
// The occurrence a reminder notification was tapped for (eventId, begin,
// end — the detail screen's key shape). singleTop + onNewIntent route a
// tap into the running activity; CalendarHost consumes and clears it.
private var requestedDetailKey by mutableStateOf<LongArray?>(null)
// Whether such a form is still open. Only then does a recreation re-read the
// intent to bring it back; a saved or dismissed one stays closed.
private var insertFormOpen = false
// A navigation a home-screen widget asked for (open a date / start a
// create). Consumed once by CalendarHost, same pattern as the detail key.
private var requestedNav by mutableStateOf<WidgetNavRequest?>(null)
// Started just for an external create, so closing its form returns to the
// app that asked instead of leaving the calendar on top of it.
private var launchedForInsert = false
// An .ics file opened/shared into the app (ACTION_VIEW/SEND). Consumed once
// by CalendarHost's import flow.
private var requestedImportUri by mutableStateOf<Uri?>(null)
// A prefilled new-event form from an external ACTION_INSERT launch (another
// app/widget asking us to create an event, issue #30). Consumed once by
// CalendarHost, which opens it in the create form for review.
private var requestedInsert by mutableStateOf<InsertRequest?>(null)
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
// opens the occurrence in the edit form. Same occurrence-key shape as the
// detail channel; consumed once by CalendarHost.
private var requestedEditKey by mutableStateOf<LongArray?>(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
@@ -115,53 +110,25 @@ class MainActivity : AppCompatActivity() {
return
}
val systemDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES
// The stored theme only arrives after the first frame, so a Light, Dark
// or Black override starts from its last mirrored copy: the matching
// backdrop and bar icons instead of the night qualifier's (#181).
val cachedTheme = WindowThemeCache.read(this)
cachedTheme?.windowBackgroundRes()?.let { window.setBackgroundDrawableResource(it) }
systemBarsDark = (cachedTheme ?: ThemeMode.SYSTEM).isDark(systemDark)
applyEdgeToEdge()
lifecycleScope.launch {
settingsPrefs.themeMode.collect { mode ->
WindowThemeCache.write(this@MainActivity, mode)
window.setBackgroundDrawableResource(mode.windowBackgroundRes() ?: R.color.window_background)
}
}
// Android hands the intent that started the task back to onCreate every
// time the activity is recreated — on a rotation, or on a restart after
// the system reclaimed the process. Re-running a launch that opens one
// specific event is what made a single reminder or widget tap re-open
// that event on every later launch, an error screen once the event had
// been deleted (#309); the user left it long ago, and the activity
// restores where they actually were along with its saved state.
//
// Only those channels are held back. An .ics import or a prefilled
// create form keeps no saved state of its own, so re-reading the intent
// is what carries one through a recreation — the create form only while
// it's still open.
val replaying = savedInstanceState != null || intent.isRelaunch()
if (!replaying) {
requests.detailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requests.editKey = intent.editEventKeyOrNull()
}
requests.nav = intent.navRequestOrNull()
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
requests.importUri = intent.importUriOrNull()
val insertOpenBefore = savedInstanceState?.getBoolean(KEY_INSERT_FORM_OPEN) == true
requests.insert = intent.createRequestOrNull()?.takeIf { !replaying || insertOpenBefore }
insertFormOpen = requests.insert != null
launchedForInsert = savedInstanceState?.getBoolean(KEY_LAUNCHED_FOR_INSERT)
?: (requests.insert != null)
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requestedNav = intent.navRequestOrNull()
requestedImportUri = intent.importUriOrNull()
requestedInsert = intent.insertRequestOrNull()
requestedEditKey = intent.editEventKeyOrNull()
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.
val settingsViewModel: SettingsViewModel = hiltViewModel()
val settings by settingsViewModel.state.collectAsStateWithLifecycle()
val darkTheme = settings.themeMode.isDark(isSystemInDarkTheme())
val darkTheme = when (settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
// onCreate can only see the night resource qualifier, not the in-app
// override — re-apply from the resolved theme so the bar icons follow
// the app's light/dark choice.
@@ -187,7 +154,6 @@ class MainActivity : AppCompatActivity() {
// weeks and switching between the week and day view.
val timelineZoom = rememberTimelineZoom(
stored = settings.timelineScale,
hours = settings.timelineHours,
onPersist = settingsViewModel::setTimelineScale,
)
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
@@ -200,20 +166,28 @@ class MainActivity : AppCompatActivity() {
CalendulaTheme(
darkTheme = darkTheme,
dynamicColor = settings.dynamicColor,
pureBlack = settings.themeMode.isPureBlack,
typography = typography,
) {
Box(modifier = Modifier.fillMaxSize()) {
CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour,
LocalShowHourGrid provides settings.showHourGrid,
LocalShowHourLines provides settings.showHourLines,
LocalTimelineZoom provides timelineZoom,
LocalMultiDayCount provides settings.multiDayCount,
LocalSoftenColors provides settings.softenColors,
) {
RootScreen(
modifier = Modifier.fillMaxSize(),
requests = requests,
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = { requestedDetailKey = null },
widgetNavRequest = requestedNav,
onWidgetNavConsumed = { requestedNav = null },
requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null },
requestedInsertForm = requestedInsert?.form,
requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
onInsertConsumed = { requestedInsert = null },
requestedEditKey = requestedEditKey,
onEditKeyConsumed = { requestedEditKey = null },
)
}
// A persistent corner marker so a debug build is never
@@ -253,46 +227,21 @@ class MainActivity : AppCompatActivity() {
)
}
override fun onStart() {
super.onStart()
// Changes made while we were away may never have notified us (#364).
calendarRepository.refresh()
}
override fun onResume() {
super.onResume()
// Reaching a running UI means startup succeeded; reset the loop trail.
CrashReporter.markHealthy(this)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(KEY_INSERT_FORM_OPEN, insertFormOpen)
outState.putBoolean(KEY_LAUNCHED_FOR_INSERT, launchedForInsert)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requests.detailKey = it }
intent.navRequestOrNull()?.let { requests.nav = it }
intent.importUriOrNull()?.let { requests.importUri = it }
intent.createRequestOrNull()?.let {
requests.insert = it
insertFormOpen = true
}
intent.editEventKeyOrNull()?.let { requests.editKey = it }
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it }
intent.importUriOrNull()?.let { requestedImportUri = it }
intent.insertRequestOrNull()?.let { requestedInsert = it }
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
}
/**
* Whether this launch is the task being resumed rather than a fresh
* delivery — Android sets the flag when an activity is started from the
* recents list, where the intent it carries is the one the task was started
* with however long ago. `onNewIntent` is the channel a real new request
* arrives on while the task lives, so nothing is lost by ignoring these.
*/
private fun Intent.isRelaunch(): Boolean =
flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
/**
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
@@ -301,9 +250,7 @@ class MainActivity : AppCompatActivity() {
private fun Intent.importUriOrNull(): Uri? {
val uri = when (action) {
Intent.ACTION_VIEW -> data
// Plain text is a create (#306), never a file to import.
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
.takeUnless { type == SHARED_TEXT_TYPE }
else -> null
} ?: return null
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
@@ -348,36 +295,6 @@ class MainActivity : AppCompatActivity() {
return InsertRequest(form, if (namesEnd) ImportSource.Insert else ImportSource.InsertOpenEnded)
}
/** Any external request to create an event: shared text or an insert intent. */
private fun Intent.createRequestOrNull(): InsertRequest? = sharedTextRequestOrNull() ?: insertRequestOrNull()
/**
* Plain text shared into us (`ACTION_SEND`) or picked from a text-selection
* menu (`ACTION_PROCESS_TEXT`), as a new event at the default time (#306).
*/
private fun Intent.sharedTextRequestOrNull(): InsertRequest? {
val shared = when (action) {
Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE) {
sharedTextOrNull(
subject = getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.toString(),
text = getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString(),
)
} else {
null
}
Intent.ACTION_PROCESS_TEXT ->
sharedTextOrNull(subject = null, text = getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString())
else -> null
} ?: return null
val form = buildInsertEventForm(
title = shared.title,
description = shared.description,
zone = TimeZone.currentSystemDefault(),
now = Clock.System.now(),
)
return InsertRequest(form, ImportSource.InsertOpenEnded)
}
/** A Long extra's value, or null when the extra is absent. */
private fun Intent.longExtraOrNull(key: String): Long? =
if (hasExtra(key)) getLongExtra(key, 0L) else null
@@ -503,9 +420,6 @@ class MainActivity : AppCompatActivity() {
// The calendar provider's authority/host. A date tap arrives as
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
private const val SHARED_TEXT_TYPE = "text/plain"
private const val KEY_INSERT_FORM_OPEN = "insert_form_open"
private const val KEY_LAUNCHED_FOR_INSERT = "launched_for_insert"
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
@@ -5,10 +5,12 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
@@ -26,7 +28,6 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.floret.android.hasPermission
import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit
import kotlin.time.Clock
@@ -132,7 +133,8 @@ class BackupWorker(
private fun notifyFailure(context: Context) {
val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!canPost) return
val manager = NotificationManagerCompat.from(context)
manager.createNotificationChannel(
@@ -1,35 +1,22 @@
package de.jeanlucmakiola.calendula.data.calendar
/**
* The colours offered when creating or editing a calendar; ARGB ints for a raw
* `CALENDAR_COLOR` / `EVENT_COLOR`.
*
* Hues rather than final fills: every surface that draws a calendar colour runs
* it through the app's own tone pass first, so these are chosen to stay
* distinguishable after it. Shared with Agendula, whose list palette these are —
* the two apps are the same family and a calendar and a task list picked from
* different-looking sets read as different products.
*
* The named entries exist for callers that need one specific hue (the managed
* special-dates calendars), so they can't drift from the swatches on offer.
* Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
* `EVENT_COLOR`. The named entries exist for callers that need one specific
* hue (the managed special-dates calendars), so they can't drift from the
* swatches offered in the colour picker.
*/
object CalendarColorPalette {
val Mauve = 0xFF7A5C6B.toInt()
val Red = 0xFFD7484A.toInt()
val Orange = 0xFFE8743B.toInt()
val Amber = 0xFFE0A32E.toInt()
val Olive = 0xFF7CA83E.toInt()
val Green = 0xFF35A06A.toInt()
val Teal = 0xFF19938C.toInt()
val Cyan = 0xFF2A9BC4.toInt()
val Blue = 0xFF3C74C8.toInt()
val Indigo = 0xFF6A5CC0.toInt()
val Purple = 0xFF9455B8.toInt()
val Pink = 0xFFC94F8E.toInt()
val Red = 0xFFD50000.toInt()
val Orange = 0xFFE67C00.toInt()
val Amber = 0xFFF6BF26.toInt()
val Green = 0xFF33B679.toInt()
val DarkGreen = 0xFF0B8043.toInt()
val Blue = 0xFF039BE5.toInt()
val Indigo = 0xFF3F51B5.toInt()
val Purple = 0xFF8E24AA.toInt()
val Graphite = 0xFF616161.toInt()
/** The full palette, in swatch-grid order. */
val all: List<Int> = listOf(
Mauve, Red, Orange, Amber, Olive, Green,
Teal, Cyan, Blue, Indigo, Purple, Pink,
)
/** The full palette, in swatch-row order. */
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
}
@@ -1,12 +1,13 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.time.toEpochMillis
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
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
@@ -14,6 +15,7 @@ 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.AttendeeRelationship
@@ -26,6 +28,7 @@ import de.jeanlucmakiola.calendula.domain.curatedForPicker
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventSearch
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.SearchCandidate
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
@@ -33,11 +36,9 @@ import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import de.jeanlucmakiola.calendula.domain.toEditForm
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDate
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
@@ -75,13 +76,21 @@ interface CalendarDataSource {
/**
* The event-colour palette the calendar's account publishes
* (`CalendarContract.Colors`, `TYPE_EVENT`), every key in provider order.
* (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped,
* thinned to visually distinct swatches when oversized (CalDAV adapters
* publish all ~147 CSS3 names, #22) and hue-sorted; see [curatedForPicker].
* Empty when the account exposes no palette (most local calendars, some
* CalDAV) — the signal that a custom colour can only be written as a raw
* `EVENT_COLOR`, which a synced calendar may drop on its next sync. The
* picker shows it curated ([curatedForPicker]); matching a colour that came
* from outside the account runs against this full set, since those keys are
* all the calendar accepts.
* `EVENT_COLOR`, which a synced calendar may drop on its next sync.
*/
fun eventColorPalette(calendarId: Long): List<EventColorOption>
/**
* The same palette **uncurated** — every key the account publishes, in
* provider order. Curation is a display concession (it folds look-alikes and
* drops the neutrals outright from an oversized palette), so matching a
* colour that came from outside the account has to run against the full set:
* those keys are all the calendar accepts. See [eventColorPalette].
*/
fun publishedEventColors(calendarId: Long): List<EventColorOption>
@@ -229,10 +238,8 @@ interface CalendarDataSource {
* and cancelled occurrences) to [targetCalendarId], returning the new
* `Events._ID`. `CALENDAR_ID` is sync-adapter-owned and can't be updated in
* place, so this is copy+delete: the row is re-inserted on the target (its
* `UID_2445` preserved), its reminders and editable guests copied, its
* exceptions turned into EXDATEs (modified ones also into standalone events,
* as the new row has no `_sync_id` to attach them to), the
* [original]→[updated] field edits applied, then the source
* `UID_2445` preserved), its exceptions, reminders and editable guests
* replayed, the [original]→[updated] field edits applied, then the source
* deleted. The source is removed only once the copy fully succeeds; a failure
* before that rolls the new event back, leaving the original untouched.
* [allDayReminderTimeMinutes]: see [insertEvent].
@@ -316,7 +323,8 @@ class AndroidCalendarDataSource @Inject constructor(
private val registeredObservers = mutableSetOf<ContentObserver>()
private fun hasCalendarPermission(): Boolean =
context.hasPermission(Manifest.permission.READ_CALENDAR)
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
/**
* Attach any not-yet-registered observers to the provider, but only once the
@@ -350,7 +358,7 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarProjection.COLUMNS,
null, null,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
)?.use { c -> c.mapRows { toCalendarSource() } } ?: emptyList()
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
}
/**
@@ -367,21 +375,8 @@ class AndroidCalendarDataSource @Inject constructor(
)
.build()
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long =
insertLocalCalendar(displayName, color, "create local calendar") { putDescription(description) }
/**
* Insert an owned, visible, syncing calendar on the Calendula local account;
* [extra] adds the columns that tell the kinds apart. [what] names the write
* in the failure — never the calendar name, which can reach a crash report.
*/
private fun insertLocalCalendar(
displayName: String,
color: Int,
what: String,
extra: ContentValues.() -> Unit,
): Long {
val name = displayName.trim()
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val values = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
@@ -396,14 +391,16 @@ class AndroidCalendarDataSource @Inject constructor(
)
put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
extra()
putDescription(description)
}
val uri = resolver.insert(localCalendarsUri(), values) ?: throw WriteFailedException(what)
val uri = resolver.insert(localCalendarsUri(), values)
// No calendar name in the message — it can reach a crash report.
?: throw WriteFailedException("create local calendar")
return ContentUris.parseId(uri)
}
override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) {
val name = displayName.trim()
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val values = ContentValues().apply {
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
put(CalendarContract.Calendars.NAME, name)
@@ -463,15 +460,33 @@ class AndroidCalendarDataSource @Inject constructor(
}
override fun canWriteCalendars(): Boolean =
context.hasPermission(Manifest.permission.WRITE_CALENDAR)
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long =
insertLocalCalendar(displayName, color, "create managed calendar") {
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val values = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
put(CalendarContract.Calendars.OWNER_ACCOUNT, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.NAME, name)
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
put(
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
CalendarContract.Calendars.CAL_ACCESS_OWNER,
)
put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
// The marker that identifies this as our special-dates calendar,
// independent of the (user-editable) display name. CAL_SYNC1 already
// holds the description, so the marker lives in CAL_SYNC2.
put(MANAGED_MARKER_COLUMN, markerFor(type))
}
val uri = resolver.insert(localCalendarsUri(), values)
?: throw WriteFailedException("create managed calendar")
return ContentUris.parseId(uri)
}
override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query(
CalendarContract.Calendars.CONTENT_URI,
@@ -535,16 +550,22 @@ class AndroidCalendarDataSource @Inject constructor(
// Seeded once, then never re-touched by sync — so a user who moves a
// birthday reminder keeps their change. Encoded against the upcoming
// occurrence, not the ancient DTSTART anchor (wrong timezone offset).
val anchor = Instant.ofEpochMilli(times.dtStartMillis).atZone(ZoneOffset.UTC).toLocalDate()
insertReminderRows(
eventId,
managedAllDayReminderMinutes(utcDate(times.dtStartMillis), form.reminders, allDayReminderTimeMinutes),
managedAllDayReminderMinutes(anchor, form.reminders, allDayReminderTimeMinutes),
)
return eventId
}
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
if (values.isEmpty()) return
updateEventRow(eventId, values.toContentValues(), "update managed event id=$eventId")
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(),
null, null,
)
if (rows == 0) throw WriteFailedException("update managed event id=$eventId")
}
override fun applyManagedCalendarReminders(
@@ -552,34 +573,48 @@ class AndroidCalendarDataSource @Inject constructor(
allDayReminderTimeMinutes: Int,
minutes: List<Int>,
) {
// Only our own mirror events, so a stray user event in this calendar
// isn't all-day-re-encoded or stripped of its own reminders.
queryManagedEvents(calendarId).forEach { event ->
// Managed events are all-day: DTSTART is a UTC midnight anchor.
reconcileReminders(
event.eventId,
managedAllDayReminderMinutes(utcDate(event.dtStartMillis), minutes, allDayReminderTimeMinutes),
)
resolver.query(
CalendarContract.Events.CONTENT_URI,
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART),
// Only our own mirror events, so a stray user event in this calendar
// isn't all-day-re-encoded or stripped of its own reminders.
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
"${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.UID_2445} LIKE ?",
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
null,
)?.use { c ->
while (c.moveToNext()) {
val eventId = c.getLong(0)
// Managed events are all-day: DTSTART is a UTC midnight anchor.
val anchor = Instant.ofEpochMilli(c.getLong(1))
.atZone(ZoneOffset.UTC).toLocalDate()
reconcileReminders(
eventId,
managedAllDayReminderMinutes(anchor, minutes, allDayReminderTimeMinutes),
)
}
}
}
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)
}.build()
return resolver.query(
instancesUri(beginMillis, endMillis),
uri,
InstanceProjection.COLUMNS,
// Hide cancelled occurrences: "delete only this event" writes a
// cancelled exception for the one instance (#47). A NULL status is a
// normal, un-cancelled event, so it must survive the filter — a bare
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
// DELETED = 0: a provider that soft-deletes may keep the row's
// instances until a purge (#364).
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
"${CalendarContract.Events.DELETED} = 0",
"${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}",
null,
CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList()
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
}
override fun searchEvents(query: String): List<SearchCandidate> {
@@ -683,9 +718,13 @@ class AndroidCalendarDataSource @Inject constructor(
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(
instancesUri(beginMillis, endMillis),
uri,
arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END),
"${CalendarContract.Instances.EVENT_ID} = ?",
arrayOf(eventId.toString()),
@@ -699,7 +738,7 @@ class AndroidCalendarDataSource @Inject constructor(
val attendees = queryAttendees(eventId)
val reminders = queryReminders(eventId)
return resolver.query(
eventUri(eventId),
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
EventDetailProjection.COLUMNS,
null, null, null,
)?.use { c ->
@@ -710,6 +749,9 @@ class AndroidCalendarDataSource @Inject constructor(
}
}
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
publishedEventColors(calendarId).curatedForPicker()
override fun publishedEventColors(calendarId: Long): List<EventColorOption> {
val account = calendarAccount(calendarId) ?: return emptyList()
return resolver.query(
@@ -762,10 +804,11 @@ class AndroidCalendarDataSource @Inject constructor(
null,
CalendarContract.Events.DTSTART + " ASC",
)?.use { c ->
c.mapRows {
val eventId = getLong(EventExportProjection.IDX_ID)
val calendarId = getLong(EventExportProjection.IDX_CALENDAR_ID)
toIcsEvent(
c.mapAll {
val reader = CursorColumnReader(c)
val eventId = reader.getLong(EventExportProjection.IDX_ID)
val calendarId = reader.getLong(EventExportProjection.IDX_CALENDAR_ID)
reader.toIcsEvent(
reminderMinutes = queryReminders(eventId).map { it.minutes },
calendarName = names[calendarId],
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
@@ -800,7 +843,7 @@ class AndroidCalendarDataSource @Inject constructor(
calendarId = calendarId,
// Preserve the file's UID so a re-import dedups against it; mint one
// only when the source event carried none.
uid = event.uid?.takeIf { it.isNotBlank() } ?: mintUid(),
uid = event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
palette = colorPalette,
)
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
@@ -813,7 +856,8 @@ class AndroidCalendarDataSource @Inject constructor(
// (see [importedAllDayReminderDate]).
val reminderDate = if (event.isAllDay) {
importedAllDayReminderDate(
startDate = utcDate(event.start.toEpochMilliseconds()),
startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds())
.atZone(ZoneOffset.UTC).toLocalDate(),
recurrenceRule = event.recurrenceRule,
today = LocalDate.now(zone),
)
@@ -831,7 +875,24 @@ class AndroidCalendarDataSource @Inject constructor(
} else {
minutes
}
insertReminderRow(eventId, providerMinutes)
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, providerMinutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
// The event row is already in. A reminder that won't attach costs
// that one alarm, not the event: throwing here would have the import
// count an event it did create as failed, and the retry would then
// skip it as a duplicate.
val attached = try {
resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder)
} catch (e: Exception) {
Log.w(TAG, "Reminder insert threw for imported event $eventId", e)
null
}
if (attached == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
}
}
return eventId
}
@@ -887,13 +948,18 @@ class AndroidCalendarDataSource @Inject constructor(
// back to a stable synthesised UID at export time (deriveIcsUid).
val values = buildEventInsertValues(
form,
uid = mintUid(),
uid = "${UUID.randomUUID()}@calendula",
times = form.toWriteTimes(ZoneId.systemDefault()),
) + eventColorColumns(form.colorKey, form.color)
// A new row has nothing to clear: unset columns inherit the
// calendar's colour, so only the key/raw values are written.
.filterValues { it != null }
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
).toContentValues().apply {
// A null colour just leaves both columns unset (the event inherits
// its calendar's colour), so only the key/raw cases are written.
when {
form.colorKey != null ->
put(CalendarContract.Events.EVENT_COLOR_KEY, form.colorKey)
form.color != null -> put(CalendarContract.Events.EVENT_COLOR, form.color)
}
}
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
val eventId = ContentUris.parseId(uri)
seedReminders(eventId, form, allDayReminderTimeMinutes)
@@ -914,32 +980,16 @@ class AndroidCalendarDataSource @Inject constructor(
/** Insert one `METHOD_ALERT` reminder row per raw provider offset. */
private fun insertReminderRows(eventId: Long, encodedMinutes: List<Int>) {
encodedMinutes.forEach { insertReminderRow(eventId, it) }
}
/**
* Best effort: the event row is already in, so a reminder that won't attach
* costs that one alarm, not the event. Throwing would report an event that
* was created as failed — and an import retry would then skip it as a
* duplicate.
*/
private fun insertReminderRow(
eventId: Long,
minutes: Int,
method: Int = CalendarContract.Reminders.METHOD_ALERT,
) {
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, method)
encodedMinutes.forEach { minutes ->
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
}
}
val attached = try {
resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder)
} catch (e: Exception) {
Log.w(TAG, "Reminder insert threw for event $eventId", e)
null
}
if (attached == null) Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
}
/**
@@ -976,7 +1026,12 @@ class AndroidCalendarDataSource @Inject constructor(
zone = ZoneId.systemDefault(),
)
if (values.isNotEmpty()) {
updateEventRow(eventId, values.toContentValues(), "update event id=$eventId")
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(),
null, null,
)
if (rows == 0) throw WriteFailedException("update event id=$eventId")
}
// Untouched reminder sets are left alone so unrelated edits can't
// disturb provider rows the form never knew about. The diff is on the
@@ -1009,17 +1064,16 @@ class AndroidCalendarDataSource @Inject constructor(
?: throw WriteFailedException("read event to move id=$eventId")
// Keep the source UID so an .ics backup dedups and a sync adapter can
// recognise the moved event; mint one only if the source carried none.
val uid = master.uid?.takeIf { it.isNotEmpty() } ?: mintUid()
val uid = master.uid?.takeIf { it.isNotEmpty() } ?: "${UUID.randomUUID()}@calendula"
val newEventId = resolver.insert(
CalendarContract.Events.CONTENT_URI,
buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(),
)?.let(ContentUris::parseId)
?: throw WriteFailedException("insert moved event into calendar id=$targetCalendarId")
val detached = mutableListOf<Long>()
try {
copyReminderRows(fromEventId = eventId, toEventId = newEventId)
insertAttendees(newEventId, editableAttendees(eventId))
copyExceptions(eventId, newEventId, targetCalendarId, allDayReminderTimeMinutes, detached)
copyExceptions(fromEventId = eventId, toEventId = newEventId)
// Apply the user's field edits exactly as a same-calendar "all
// events" save would: the moved master carries the source's values,
// so the dirty diff against [original] writes only what changed
@@ -1029,7 +1083,6 @@ class AndroidCalendarDataSource @Inject constructor(
// Undo the partial copy so a failed move leaves nothing behind; the
// source is still intact (it's deleted only past this point).
runCatching { deleteEvent(newEventId) }
detached.forEach(::rollBackDetached)
throw t
}
deleteEvent(eventId)
@@ -1038,7 +1091,7 @@ class AndroidCalendarDataSource @Inject constructor(
/** The master row of the event to move, as a verbatim-insert snapshot. */
private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query(
eventUri(eventId),
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
MoveMasterProjection.COLUMNS,
null, null, null,
)?.use { c ->
@@ -1050,7 +1103,8 @@ class AndroidCalendarDataSource @Inject constructor(
title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(),
isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0,
dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART),
dtEndMillis = r.getLongOrNull(MoveMasterProjection.IDX_DTEND),
dtEndMillis = r.getLong(MoveMasterProjection.IDX_DTEND)
.takeUnless { r.isNull(MoveMasterProjection.IDX_DTEND) },
duration = r.getString(MoveMasterProjection.IDX_DURATION),
rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() },
rdate = r.getString(MoveMasterProjection.IDX_RDATE),
@@ -1058,7 +1112,8 @@ class AndroidCalendarDataSource @Inject constructor(
timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE),
availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY),
accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL),
status = r.getIntOrNull(MoveMasterProjection.IDX_STATUS),
status = r.getInt(MoveMasterProjection.IDX_STATUS)
.takeUnless { r.isNull(MoveMasterProjection.IDX_STATUS) },
location = r.getString(MoveMasterProjection.IDX_LOCATION),
description = r.getString(MoveMasterProjection.IDX_DESCRIPTION),
),
@@ -1075,7 +1130,16 @@ class AndroidCalendarDataSource @Inject constructor(
arrayOf(fromEventId.toString()),
null,
)?.use { c ->
while (c.moveToNext()) insertReminderRow(toEventId, minutes = c.getInt(0), method = c.getInt(1))
while (c.moveToNext()) {
val values = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, toEventId)
put(CalendarContract.Reminders.MINUTES, c.getInt(0))
put(CalendarContract.Reminders.METHOD, c.getInt(1))
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, values) == null) {
Log.w(TAG, "Failed to copy a reminder to moved event $toEventId")
}
}
} ?: Unit
/**
@@ -1095,33 +1159,44 @@ class AndroidCalendarDataSource @Inject constructor(
}
/**
* Carry every exception of the series [fromEventId] over to the moved series
* [toEventId]. The moved row has no `_sync_id` yet, so exception rows would
* never attach to it ([deleteOccurrence], [detachOccurrence]): a cancelled
* occurrence becomes an EXDATE, a modified one an EXDATE plus a standalone
* event on [targetCalendarId], its id added to [detached] for rollback.
* Replay every exception of the series [fromEventId] against the moved series
* [toEventId]. The copy preserved the recurrence skeleton, so each occurrence
* time still resolves: a cancelled occurrence is re-hidden with a cancelled
* exception, a modified one is re-inserted with its fields (then its reminders
* reconciled and editable guests copied). No-op for a non-recurring event.
*/
private fun copyExceptions(
fromEventId: Long,
toEventId: Long,
targetCalendarId: Long,
allDayReminderTimeMinutes: Int,
detached: MutableList<Long>,
) {
val exceptions = queryExceptionRows(fromEventId)
if (exceptions.isEmpty()) return
val zone = TimeZone.currentSystemDefault()
exceptions.filterNot { it.isCancelled }.forEach { ex ->
val detail = eventDetail(ex.exceptionEventId, allDayReminderTimeMinutes)
?: throw WriteFailedException("read modified occurrence id=${ex.exceptionEventId}")
val endMillis = ex.dtEndMillis ?: (ex.dtStartMillis + parseRfc2445DurationMillis(ex.duration))
val form = detail.toEditForm(ex.dtStartMillis, endMillis, zone)
.copy(calendarId = targetCalendarId)
.toDetachedOccurrence()
detached += insertEvent(form, allDayReminderTimeMinutes)
private fun copyExceptions(fromEventId: Long, toEventId: Long) {
queryExceptionRows(fromEventId).forEach { ex ->
if (ex.isCancelled) {
val values = ContentValues().apply {
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, ex.originalInstanceMillis)
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
}
resolver.insert(
ContentUris.withAppendedId(
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
),
values,
) ?: throw WriteFailedException("copy cancelled occurrence to event id=$toEventId")
} else {
val uri = resolver.insert(
ContentUris.withAppendedId(
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
),
buildCopiedExceptionValues(ex).toContentValues(),
) ?: throw WriteFailedException("copy modified occurrence to event id=$toEventId")
val newExceptionId = ContentUris.parseId(uri)
// The provider may clone the parent's reminders onto the new
// exception; reconcile to the source exception's exact set so
// they neither double nor drop. Same DTSTART → same all-day
// encoding, so the stored offsets match directly.
reconcileReminders(
newExceptionId,
queryReminders(ex.exceptionEventId).map { it.minutes },
)
insertAttendees(newExceptionId, editableAttendees(ex.exceptionEventId))
}
}
val values = querySeriesRow(toEventId).exdateValues(exceptions.map { it.originalInstanceMillis })
updateEventRow(toEventId, values, "exdate moved exceptions event id=$toEventId")
}
/** The series' exception rows (modified + cancelled), oldest occurrence first. */
@@ -1132,14 +1207,26 @@ class AndroidCalendarDataSource @Inject constructor(
arrayOf(seriesEventId.toString()),
CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC",
)?.use { c ->
c.mapRows {
c.mapAll {
val r = CursorColumnReader(c)
val status = r.getInt(ExceptionProjection.IDX_STATUS)
.takeUnless { r.isNull(ExceptionProjection.IDX_STATUS) }
ExceptionRowSnapshot(
exceptionEventId = getLong(ExceptionProjection.IDX_ID),
originalInstanceMillis = getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
isCancelled = getIntOrNull(ExceptionProjection.IDX_STATUS) == CalendarContract.Events.STATUS_CANCELED,
dtStartMillis = getLong(ExceptionProjection.IDX_DTSTART),
dtEndMillis = getLongOrNull(ExceptionProjection.IDX_DTEND),
duration = getString(ExceptionProjection.IDX_DURATION),
exceptionEventId = r.getLong(ExceptionProjection.IDX_ID),
originalInstanceMillis = r.getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
isCancelled = status == CalendarContract.Events.STATUS_CANCELED,
status = status,
title = r.getString(ExceptionProjection.IDX_TITLE).orEmpty(),
isAllDay = r.getInt(ExceptionProjection.IDX_ALL_DAY) != 0,
dtStartMillis = r.getLong(ExceptionProjection.IDX_DTSTART),
dtEndMillis = r.getLong(ExceptionProjection.IDX_DTEND)
.takeUnless { r.isNull(ExceptionProjection.IDX_DTEND) },
duration = r.getString(ExceptionProjection.IDX_DURATION),
timezone = r.getString(ExceptionProjection.IDX_EVENT_TIMEZONE),
availability = r.getInt(ExceptionProjection.IDX_AVAILABILITY),
accessLevel = r.getInt(ExceptionProjection.IDX_ACCESS_LEVEL),
location = r.getString(ExceptionProjection.IDX_LOCATION),
description = r.getString(ExceptionProjection.IDX_DESCRIPTION),
)
}
} ?: emptyList()
@@ -1174,13 +1261,6 @@ class AndroidCalendarDataSource @Inject constructor(
values.toContentValues(),
) ?: throw WriteFailedException("modify occurrence event id=$eventId begin=$beginMillis")
val exceptionId = ContentUris.parseId(uri)
form.colorKey?.let { key ->
resolver.update(
eventUri(exceptionId),
ContentValues().apply { put(CalendarContract.Events.EVENT_COLOR_KEY, key) },
null, null,
)
}
// 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))
@@ -1230,19 +1310,34 @@ class AndroidCalendarDataSource @Inject constructor(
// a fresh UID — the detached row is a separate event now, and sharing the
// parent's would collide with it in .ics restore dedup.
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
val values = buildOccurrenceExdateValues(
existingExdate = row.exdate,
occurrenceMillis = beginMillis,
dtStartMillis = row.dtStartMillis,
rrule = row.rrule,
rdate = row.rdate,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
)
// Rows touched, not occurrences excluded — 1 whenever the series row still
// exists. It catches the row disappearing under us, not an EXDATE the
// provider's expansion fails to match.
try {
updateEventRow(
eventId,
row.exdateValues(listOf(beginMillis)),
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
val updatedRows = try {
resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(), null, null,
)
} catch (t: Throwable) {
rollBackDetached(detachedId)
throw t
}
if (updatedRows == 0) {
rollBackDetached(detachedId)
throw WriteFailedException(
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
)
}
return detachedId
}
@@ -1315,11 +1410,23 @@ class AndroidCalendarDataSource @Inject constructor(
updated = updated,
zone = ZoneId.systemDefault(),
) ?: return
val values = querySeriesRow(newEventId).timeColumns().apply {
val row = querySeriesRow(newEventId)
val values = ContentValues().apply {
put(CalendarContract.Events.EXDATE, carried)
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
put(CalendarContract.Events.RRULE, row.rrule)
put(CalendarContract.Events.DURATION, row.duration)
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
put(CalendarContract.Events.ALL_DAY, row.allDay)
}
try {
updateEventRow(newEventId, values, "carry exdate onto split series id=$newEventId")
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, newEventId),
values, null, null,
)
if (rows == 0) {
throw WriteFailedException("carry exdate onto split series id=$newEventId")
}
} catch (t: Throwable) {
runCatching { deleteEvent(newEventId) }
throw t
@@ -1347,18 +1454,29 @@ class AndroidCalendarDataSource @Inject constructor(
*/
private fun truncateSeries(eventId: Long, row: SeriesRow, beginMillis: Long) {
requireNotNull(row.rrule) { "truncateSeries needs a recurring row" }
val values = row.timeColumns().apply {
val values = ContentValues().apply {
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
put(CalendarContract.Events.DURATION, row.duration)
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
put(CalendarContract.Events.ALL_DAY, row.allDay)
put(
CalendarContract.Events.RRULE,
rruleTruncatedAt(row.rrule, untilUtcMillis = row.truncationCutoff(beginMillis)),
)
}
updateEventRow(eventId, values, "truncate series event id=$eventId begin=$beginMillis")
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values,
null, null,
)
if (rows == 0) {
throw WriteFailedException("truncate series event id=$eventId begin=$beginMillis")
}
}
/** The series anchor: every time-related column of the Events row. */
private fun querySeriesRow(eventId: Long): SeriesRow = resolver.query(
eventUri(eventId),
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
arrayOf(
CalendarContract.Events.DTSTART,
CalendarContract.Events.RRULE,
@@ -1406,30 +1524,6 @@ class AndroidCalendarDataSource @Inject constructor(
*/
val recurs: Boolean get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
/**
* The time/recurrence set a series write carries together: the provider
* re-expands instances only from what the update itself holds.
*/
fun timeColumns(): ContentValues = ContentValues().apply {
put(CalendarContract.Events.DTSTART, dtStartMillis)
put(CalendarContract.Events.RRULE, rrule)
put(CalendarContract.Events.DURATION, duration)
put(CalendarContract.Events.EVENT_TIMEZONE, timezone)
put(CalendarContract.Events.ALL_DAY, allDay)
}
/** This row's columns with [occurrences] added to EXDATE ([buildOccurrencesExdateValues]). */
fun exdateValues(occurrences: List<Long>): Map<String, Any?> = buildOccurrencesExdateValues(
existingExdate = exdate,
occurrences = occurrences,
dtStartMillis = dtStartMillis,
rrule = rrule,
rdate = rdate,
duration = duration,
timezone = timezone,
allDay = allDay,
)
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
beginMillis = beginMillis,
@@ -1455,10 +1549,20 @@ class AndroidCalendarDataSource @Inject constructor(
arrayOf(eventId.toString(), minutes.toString()),
)
}
insertReminderRows(eventId, (target - existing).toList())
(target - existing).forEach { minutes ->
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
}
}
}
private fun EventAttendee.key(): String = emailKey(email)
/** 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> {
@@ -1510,7 +1614,7 @@ class AndroidCalendarDataSource @Inject constructor(
val relationship: Int,
val type: Int,
) {
val key: String get() = emailKey(email)
val key: String get() = email.trim().lowercase()
}
private fun queryAttendeeRows(eventId: Long): List<AttendeeRow> = resolver.query(
@@ -1575,20 +1679,17 @@ class AndroidCalendarDataSource @Inject constructor(
)
}
}
insertAttendees(eventId, target.filterNot { it.key() in editableKeys })
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 eventUri(eventId: Long): Uri =
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
/** Update the Events row [eventId]; [what] names the write when no row was touched. */
private fun updateEventRow(eventId: Long, values: ContentValues, what: String) {
if (resolver.update(eventUri(eventId), values, null, null) == 0) throw WriteFailedException(what)
}
private fun updateEventRow(eventId: Long, values: Map<String, Any?>, what: String) =
updateEventRow(eventId, values.toContentValues(), what)
private fun Map<String, Any?>.toContentValues(): ContentValues =
ContentValues().also { cv ->
forEach { (column, value) ->
@@ -1605,7 +1706,7 @@ class AndroidCalendarDataSource @Inject constructor(
override fun deleteEvent(eventId: Long) {
val deleted = resolver.delete(
eventUri(eventId),
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
null, null,
)
if (deleted == 0) throw WriteFailedException("delete event id=$eventId")
@@ -1627,11 +1728,23 @@ class AndroidCalendarDataSource @Inject constructor(
// occurrence with it (#47 on a local calendar). EXDATE needs no link.
// Calendula's own contact special-date calendars are local and hold
// yearly series, so this path is reached in normal use.
updateEventRow(
eventId,
row.exdateValues(listOf(beginMillis)),
"exdate occurrence event id=$eventId begin=$beginMillis",
val values = buildOccurrenceExdateValues(
existingExdate = row.exdate,
occurrenceMillis = beginMillis,
dtStartMillis = row.dtStartMillis,
rrule = row.rrule,
rdate = row.rdate,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
)
val updated = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(), null, null,
)
if (updated == 0) {
throw WriteFailedException("exdate occurrence event id=$eventId begin=$beginMillis")
}
return
}
// A cancelled exception row hides exactly this occurrence; the sync
@@ -1681,7 +1794,7 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Attendees.EVENT_ID + " = ?",
arrayOf(eventId.toString()),
null,
)?.use { c -> c.mapRows { toAttendee() } } ?: emptyList()
)?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList()
private fun queryReminders(eventId: Long): List<Reminder> = resolver.query(
CalendarContract.Reminders.CONTENT_URI,
@@ -1689,15 +1802,19 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Reminders.EVENT_ID + " = ?",
arrayOf(eventId.toString()),
null,
)?.use { c -> c.mapRows { toReminder() } } ?: emptyList()
)?.use { c -> c.mapAll { CursorColumnReader(c).toReminder() } } ?: emptyList()
private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource()
/** Iterate every row and map; skips nothing. */
private inline fun <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList {
while (moveToNext()) add(mapper(this@mapAll))
}
/** A fresh globally-unique `UID_2445`, for an event that carries none of its own. */
private fun mintUid(): String = "${UUID.randomUUID()}@calendula"
/** Iterate every row and map; drops nulls. */
private inline fun <T : Any> Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List<T> = buildList {
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
}
/** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */
private fun markerFor(type: SpecialDateType): String = when (type) {
@@ -1713,9 +1830,6 @@ class AndroidCalendarDataSource @Inject constructor(
private companion object {
const val TAG = "CalendarDataSource"
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */
fun emailKey(email: String): String = email.trim().lowercase()
/**
* Shared account for every app-created local calendar, so they group
* together (by account) in the filter sheet and calendar manager.
@@ -8,9 +8,8 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
return CalendarSource(
id = getLong(CalendarProjection.IDX_ID),
// Blank when the provider row has no name: the placeholder is a display
// string and belongs to the UI, where it can be localised (#329).
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME).orEmpty(),
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
?: Fallbacks.UNNAMED_CALENDAR,
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
accountType = accountType,
color = getInt(CalendarProjection.IDX_COLOR),
@@ -15,9 +15,6 @@ import kotlin.time.Instant
interface CalendarRepository {
fun calendars(): Flow<List<CalendarSource>>
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
/** Re-query every open [calendars] / [instances] flow. */
fun refresh()
suspend fun eventDetail(eventId: Long): EventDetail
/**
@@ -29,7 +26,7 @@ interface CalendarRepository {
/**
* The event-colour palette a calendar's account publishes; empty when it
* exposes none (see [CalendarDataSource.publishedEventColors]).
* exposes none (see [CalendarDataSource.eventColorPalette]).
*/
suspend fun eventColorPalette(calendarId: Long): List<EventColorOption>
@@ -1,11 +1,10 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.util.Log
import de.jeanlucmakiola.floret.di.IoDispatcher
import de.jeanlucmakiola.floret.time.toEpochMillis
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.curatedForPicker
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
@@ -15,7 +14,6 @@ import de.jeanlucmakiola.calendula.domain.SearchCandidate
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine
@@ -53,37 +51,21 @@ class CalendarRepositoryImpl @Inject constructor(
private suspend fun allDayReminderTimeMinutes(): Int =
settingsPrefs.allDayReminderTimeMinutes.first()
// DROP_OLDEST: tryEmit never fails, so a tick that lands while a subscriber
// still holds the previous one is never lost (#364).
private val ticks = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/**
* Bumped on every [refresh], so one tick's calendar read can be
* Bumped on every provider notification, so one tick's calendar read can be
* shared by everything that needs it (see [calendarsSnapshot]).
*/
private val generation = AtomicLong(0L)
init {
dataSource.registerChangeListener(::refresh)
}
// Writes re-query on their own too: some providers never notify the
// observer, which left every view stale until a restart (#364).
override fun refresh() {
generation.incrementAndGet()
ticks.tryEmit(Unit)
}
/** Run a provider write on [io], then re-query whether or not it threw. */
private suspend fun <T> write(block: suspend () -> T): T = withContext(io) {
try {
block()
} finally {
refresh()
dataSource.registerChangeListener {
generation.incrementAndGet()
ticks.tryEmit(Unit)
}
}
@@ -126,7 +108,8 @@ class CalendarRepositoryImpl @Inject constructor(
beginMillis = range.start.toEpochMillis(),
endMillis = range.endInclusive.toEpochMillis(),
),
switchedOffCalendarIds = switchedOffCalendarIds(),
switchedOffCalendarIds = invisibleCalendarIds() +
prefs.pendingDisabledCalendarIds.first(),
)
},
prefs.hiddenCalendarIds,
@@ -151,10 +134,6 @@ class CalendarRepositoryImpl @Inject constructor(
.filterNot { it.isVisibleInSystem }
.mapTo(mutableSetOf()) { it.id }
/** [invisibleCalendarIds] plus the switch-offs still waiting to be written. */
private suspend fun switchedOffCalendarIds(): Set<Long> =
invisibleCalendarIds() + prefs.pendingDisabledCalendarIds.first()
private val calendarsLock = Mutex()
private var cachedGeneration = -1L
private var cachedPending: Set<Long>? = null
@@ -188,19 +167,21 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) {
if (query.isBlank()) return@withContext emptyList()
val excluded = prefs.hiddenCalendarIds.first() + switchedOffCalendarIds()
val excluded = prefs.hiddenCalendarIds.first() +
prefs.pendingDisabledCalendarIds.first() +
invisibleCalendarIds()
dataSource.searchEvents(query)
.let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } }
}
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
withContext(io) { dataSource.publishedEventColors(calendarId).curatedForPicker() }
withContext(io) { dataSource.eventColorPalette(calendarId) }
override suspend fun createLocalCalendar(
displayName: String,
color: Int,
description: String?,
): Long = write {
): Long = withContext(io) {
dataSource.createLocalCalendar(displayName, color, description)
}
@@ -209,22 +190,23 @@ class CalendarRepositoryImpl @Inject constructor(
displayName: String,
color: Int,
description: String?,
) = write { dataSource.updateCalendar(id, displayName, color, description) }
) = withContext(io) { dataSource.updateCalendar(id, displayName, color, description) }
override suspend fun deleteCalendar(id: Long) =
write { dataSource.deleteCalendar(id) }
withContext(io) { dataSource.deleteCalendar(id) }
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
write {
val canWrite = dataSource.canWriteCalendars()
if (canWrite) ids.forEach { dataSource.setCalendarVisible(it, visible) }
// Nothing of ours is left waiting for the provider once the write
// lands, and switching one back on retires its entry. With read-only
// permission a switch-off still works, app-side, and the reconciler
// flushes it if WRITE_CALENDAR ever arrives.
if (canWrite || visible) {
withContext(io) {
if (dataSource.canWriteCalendars()) {
ids.forEach { dataSource.setCalendarVisible(it, visible) }
// Nothing of ours is left waiting for the provider once the
// write lands (and switching one back on retires its entry).
prefs.removePendingDisabledCalendarIds(ids)
} else if (visible) {
prefs.removePendingDisabledCalendarIds(ids)
} else {
// Read-only permission: the switch still works, app-side, and
// the reconciler flushes it if WRITE_CALENDAR ever arrives.
prefs.addPendingDisabledCalendarIds(ids)
}
}
@@ -235,7 +217,7 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun importEvents(
targetCalendarId: Long,
events: List<ParsedIcsEvent>,
): IcsImportSummary = write {
): IcsImportSummary = withContext(io) {
val existing = dataSource.existingUids(targetCalendarId)
// Both are per-calendar, not per-event: looking them up once keeps a
// thousand-event restore to two extra queries. The palette is the
@@ -286,7 +268,7 @@ class CalendarRepositoryImpl @Inject constructor(
)
}
override suspend fun createEvent(form: EventForm): Long = write {
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
dataSource.insertEvent(form, allDayReminderTimeMinutes())
}
@@ -294,11 +276,11 @@ class CalendarRepositoryImpl @Inject constructor(
eventId: Long,
original: EventForm,
updated: EventForm,
) = write {
) = withContext(io) {
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
}
override suspend fun deleteEvent(eventId: Long) = write {
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
dataSource.deleteEvent(eventId)
}
@@ -307,7 +289,7 @@ class CalendarRepositoryImpl @Inject constructor(
targetCalendarId: Long,
original: EventForm,
updated: EventForm,
): Long = write {
): Long = withContext(io) {
dataSource.moveEvent(
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
)
@@ -318,7 +300,7 @@ class CalendarRepositoryImpl @Inject constructor(
beginMillis: Long,
original: EventForm,
form: EventForm,
): Long = write {
): Long = withContext(io) {
dataSource.updateOccurrence(
eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
)
@@ -329,20 +311,20 @@ class CalendarRepositoryImpl @Inject constructor(
beginMillis: Long,
original: EventForm,
updated: EventForm,
): Long = write {
): Long = withContext(io) {
dataSource.updateEventFromOccurrence(
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
)
}
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write {
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
dataSource.deleteOccurrence(eventId, beginMillis)
}
override suspend fun deleteEventFromOccurrence(
eventId: Long,
beginMillis: Long,
) = write {
) = withContext(io) {
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
}
@@ -4,15 +4,15 @@ import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.calendarVisibilityPlan
import de.jeanlucmakiola.calendula.domain.hasSystemHiddenCalendars
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@@ -47,7 +47,7 @@ class CalendarVisibilityReconciler @Inject constructor(
// Settled ahead of the permission gate, so an update installed
// before the first grant can't later look like an upgrade.
if (!isUpgradeInstall()) settleNoticeOnce(pending = false)
if (!context.hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
if (!hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
val pending = prefs.pendingDisabledCalendarIds.first()
val noticeSettled = prefs.visibilityNoticePending.first() != null
if (pending.isEmpty() && noticeSettled) return@withContext
@@ -56,7 +56,7 @@ class CalendarVisibilityReconciler @Inject constructor(
// Both decisions below are one-way, so leave them to the next run.
if (calendars.isEmpty()) return@withContext
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
if (pending.isEmpty() || !context.hasPermission(Manifest.permission.WRITE_CALENDAR)) {
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
return@withContext
}
val plan = calendarVisibilityPlan(calendars, pending)
@@ -97,6 +97,9 @@ class CalendarVisibilityReconciler @Inject constructor(
false
}
private fun hasPermission(permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
/** Lets non-injectable entry points (the Application) reach the reconciler. */
@EntryPoint
@InstallIn(SingletonComponent::class)
@@ -1,11 +1,6 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.database.Cursor
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
/**
* Read-only view over a single row's columns by index. Lets the mappers work
@@ -25,33 +20,3 @@ internal class CursorColumnReader(private val cursor: Cursor) : ColumnReader {
override fun getInt(index: Int): Int = cursor.getInt(index)
override fun isNull(index: Int): Boolean = cursor.isNull(index)
}
internal fun ColumnReader.getIntOrNull(index: Int): Int? = if (isNull(index)) null else getInt(index)
internal fun ColumnReader.getLongOrNull(index: Int): Long? = if (isNull(index)) null else getLong(index)
/**
* The row's end: DTEND, or DTSTART + DURATION for a recurring row, which stores
* no DTEND. Unclamped; a missing/blank both means a zero-length event.
*/
internal fun ColumnReader.endMillis(dtStartMillis: Long, dtEndIndex: Int, durationIndex: Int): Long =
getLongOrNull(dtEndIndex) ?: (dtStartMillis + parseRfc2445DurationMillis(getString(durationIndex)))
/** STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed. */
internal fun ColumnReader.eventStatus(index: Int): EventStatus =
getIntOrNull(index)?.let(::mapEventStatus) ?: EventStatus.Confirmed
/** Map every row, reading each through one [CursorColumnReader]. */
internal inline fun <T> Cursor.mapRows(map: ColumnReader.() -> T): List<T> {
val reader = CursorColumnReader(this)
return buildList { while (moveToNext()) add(reader.map()) }
}
/** [mapRows], dropping the rows [map] returns null for. */
internal inline fun <T : Any> Cursor.mapRowsNotNull(map: ColumnReader.() -> T?): List<T> {
val reader = CursorColumnReader(this)
return buildList { while (moveToNext()) reader.map()?.let(::add) }
}
/** The UTC calendar day of [millis] — an all-day row's date, since it sits at UTC midnight. */
internal fun utcDate(millis: Long): LocalDate = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
@@ -15,7 +15,9 @@ import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
private const val TAG = "EventDetailMapper"
@@ -46,8 +48,11 @@ internal fun ColumnReader.toEventDetailCore(
// — the same trap as the pre-1970 DTSTART bug above (issue #34): it would
// surface as the generic error screen with no way to open the event and fix
// it. Clamp to a zero-length event instead (matching SearchMapper).
val end = getLongOrNull(EventDetailProjection.IDX_DTEND)?.coerceAtLeast(begin)
?: (begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION)))
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))
} else {
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
}
// Kept raw (no untitled fallback): the detail screen substitutes its own
// localized placeholder, and the edit form must prefill the true value.
@@ -56,7 +61,11 @@ internal fun ColumnReader.toEventDetailCore(
// The event's own colour (null = inherits the calendar's) is kept apart
// from the resolved display colour: the edit form needs to tell the two
// cases apart, while the instance carries the calendar fallback for display.
val eventColor = getIntOrNull(EventDetailProjection.IDX_EVENT_COLOR)
val eventColor = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
null
} else {
getInt(EventDetailProjection.IDX_EVENT_COLOR)
}
val eventColorKey = getString(EventDetailProjection.IDX_EVENT_COLOR_KEY)
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
@@ -79,7 +88,7 @@ internal fun ColumnReader.toEventDetailCore(
// and detail screen speak. DTSTART is UTC midnight for all-day events, so the
// event's date is its UTC date.
val displayReminders = if (isAllDay) {
val startDate = utcDate(begin)
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
val zone = ZoneId.systemDefault()
reminders.map {
it.copy(
@@ -92,6 +101,14 @@ internal fun ColumnReader.toEventDetailCore(
reminders
}
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
// be distinguished from a present 0 — an absent status means "just confirmed".
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
EventStatus.Confirmed
} else {
mapEventStatus(getInt(EventDetailProjection.IDX_STATUS))
}
return EventDetail(
instance = instance,
description = getString(EventDetailProjection.IDX_DESCRIPTION),
@@ -99,7 +116,7 @@ internal fun ColumnReader.toEventDetailCore(
attendees = attendees,
rrule = getString(EventDetailProjection.IDX_RRULE),
reminders = displayReminders,
status = eventStatus(EventDetailProjection.IDX_STATUS),
status = status,
// BUSY and DEFAULT are both 0, so a null column folds into the same
// default these mappers already return — no isNull guard needed.
availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)),
@@ -1,19 +1,15 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.instantIn
import kotlinx.datetime.toKotlinTimeZone
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.nearestTo
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime
import kotlinx.datetime.toJavaZoneOffset
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
@@ -52,8 +48,8 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
} else {
val writeZone = writeZone(zone)
EventWriteTimes(
dtStartMillis = start.instantIn(writeZone.toKotlinTimeZone()).toEpochMilliseconds(),
dtEndMillis = end.instantIn(writeZone.toKotlinTimeZone()).toEpochMilliseconds(),
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
timezone = writeZone.id,
)
}
@@ -79,7 +75,7 @@ private fun EventForm.writeZone(deviceZone: ZoneId): ZoneId = if (isAllDay) {
private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) {
start.date.toJavaLocalDate().atStartOfDay()
} else {
start.dateTime.toJavaLocalDateTime()
start.toJavaLocalDateTime()
}
/**
@@ -185,8 +181,7 @@ internal fun buildEventUpdateValues(
// A zone change counts as a time change even when the wall-clock is
// untouched: the same 09:00 in another zone is a different instant, so
// DTSTART has to move with it. So does a switch between the two passes of
// the fall-back hour, which each [FormTime] carries (#387).
// DTSTART has to move with it.
val timesChanged = updated.start != original.start ||
updated.end != original.end ||
updated.isAllDay != original.isAllDay ||
@@ -214,23 +209,15 @@ internal fun buildEventUpdateValues(
// is a different offset from the series anchor's whenever a DST boundary
// sits between them, or whenever the zone itself changed. Working in wall
// clock keeps "09:00" meaning 09:00 at both ends.
val seriesZoned = Instant.ofEpochMilli(seriesDtStartMillis).atZone(original.writeZone(zone))
val seriesLocal = seriesZoned.toLocalDateTime()
val seriesLocal = Instant.ofEpochMilli(seriesDtStartMillis)
.atZone(original.writeZone(zone)).toLocalDateTime()
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
val shifted = seriesLocal.plus(wallClockShift)
// An all-day series anchor must sit on a UTC midnight. A pure day move
// already lands there (both ends are midnights), but *switching* a
// recurring event to all-day shifts by a time-of-day too, so snap.
val newSeriesLocal = if (updated.isAllDay) shifted.toLocalDate().atStartOfDay() else shifted
// A wall clock the fall-back hour repeats keeps the anchor's own pass, or,
// when the anchor is the edited occurrence itself, the pass the form now
// holds: a bare wall clock would always take the earlier one (#387).
val pass = if (seriesLocal == original.anchorLocal()) {
updated.start.offset?.toJavaZoneOffset()
} else {
seriesZoned.offset
}
val newSeriesStart = ZonedDateTime.ofLocal(newSeriesLocal, updated.writeZone(zone), pass)
val newSeriesStart = newSeriesLocal.atZone(updated.writeZone(zone))
put(CalendarContract.Events.DTSTART, newSeriesStart.toInstant().toEpochMilli())
put(CalendarContract.Events.DTEND, null)
put(CalendarContract.Events.RRULE, updated.rrule)
@@ -260,10 +247,6 @@ internal fun buildEventUpdateValues(
* DTSTART + DURATION itself, clearing the inherited RRULE in the process. This
* matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made
* "only this event" fail on-device (Codeberg #16).
*
* A palette key is left out: the provider crashes on an exception insert that
* carries `EVENT_COLOR_KEY` (Codeberg #338), so the caller sets it with a plain
* update once the row exists.
*/
internal fun buildOccurrenceExceptionValues(
form: EventForm,
@@ -281,7 +264,7 @@ internal fun buildOccurrenceExceptionValues(
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
if (form.colorKey == null) putAll(eventColorColumns(null, form.color))
putAll(eventColorColumns(form.colorKey, form.color))
}
/**
@@ -372,11 +355,48 @@ internal data class ExceptionRowSnapshot(
val exceptionEventId: Long,
val originalInstanceMillis: Long,
val isCancelled: Boolean,
val status: Int?,
val title: String,
val isAllDay: Boolean,
val dtStartMillis: Long,
val dtEndMillis: Long?,
val duration: String?,
val timezone: String?,
val availability: Int,
val accessLevel: Int,
val location: String?,
val description: String?,
)
/**
* Column values replaying a *modified* occurrence [snapshot] against the moved
* series via `CONTENT_EXCEPTION_URI`. Like [buildOccurrenceExceptionValues] the
* length travels as DURATION (the provider rejects DTEND on an exception). A
* cancelled occurrence is written separately (ORIGINAL_INSTANCE_TIME +
* STATUS_CANCELED) — this builder is only for the modified case.
*/
internal fun buildCopiedExceptionValues(snapshot: ExceptionRowSnapshot): Map<String, Any?> =
buildMap {
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, snapshot.originalInstanceMillis)
put(CalendarContract.Events.TITLE, snapshot.title)
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
put(
CalendarContract.Events.DURATION,
snapshot.duration?.takeIf { it.isNotBlank() }
?: rfc2445Duration(
(snapshot.dtEndMillis ?: snapshot.dtStartMillis) - snapshot.dtStartMillis,
snapshot.isAllDay,
),
)
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
}
/**
* Column values for a *cancelled*-occurrence exception row ("delete only this
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
@@ -442,24 +462,9 @@ internal fun buildOccurrenceExdateValues(
duration: String?,
timezone: String?,
allDay: Int,
): Map<String, Any?> = buildOccurrencesExdateValues(
existingExdate, listOf(occurrenceMillis), dtStartMillis, rrule, rdate, duration, timezone, allDay,
)
/** [buildOccurrenceExdateValues] excluding every one of [occurrences] in one write. */
internal fun buildOccurrencesExdateValues(
existingExdate: String?,
occurrences: List<Long>,
dtStartMillis: Long,
rrule: String?,
rdate: String?,
duration: String?,
timezone: String?,
allDay: Int,
): Map<String, Any?> = mapOf(
CalendarContract.Events.EXDATE to occurrences.fold(existingExdate) { exdate, millis ->
mergedExdate(exdate, millis, allDay != 0, timezone)
},
CalendarContract.Events.EXDATE to
mergedExdate(existingExdate, occurrenceMillis, allDay != 0, timezone),
CalendarContract.Events.DTSTART to dtStartMillis,
CalendarContract.Events.RRULE to rrule,
CalendarContract.Events.RDATE to rdate,
@@ -609,7 +614,7 @@ internal fun exdateAfter(
* optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of
* a zoned one ([exdateListZone] reads that half).
*/
internal fun exdateStamps(exdate: String?): List<String> = exdate
private fun exdateStamps(exdate: String?): List<String> = exdate
?.withoutExdateParameters()
?.split(',')
?.map { it.trim() }
@@ -879,7 +884,7 @@ internal fun buildImportedEventValues(
*/
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
val span = (endMillis - startMillis).coerceAtLeast(0)
return rfc2445Duration(if (isAllDay) span.coerceAtLeast(MILLIS_PER_DAY) else span, isAllDay)
return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S"
}
private fun EventStatus.toProviderStatus(): Int = when (this) {
@@ -1,9 +1,13 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
/**
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
@@ -29,8 +33,19 @@ internal fun ColumnReader.toIcsEvent(
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
// Unclamped: the writer renders DTEND as stored.
val end = endMillis(dtStart, EventExportProjection.IDX_DTEND, EventExportProjection.IDX_DURATION)
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
// so the writer can render DTEND. A missing/blank both means a zero-length event.
val end = when {
!isNull(EventExportProjection.IDX_DTEND) -> getLong(EventExportProjection.IDX_DTEND)
else -> dtStart + parseRfc2445DurationMillis(getString(EventExportProjection.IDX_DURATION))
}
// STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed.
val status = if (isNull(EventExportProjection.IDX_STATUS)) {
EventStatus.Confirmed
} else {
mapEventStatus(getInt(EventExportProjection.IDX_STATUS))
}
return IcsEvent(
uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart),
@@ -53,14 +68,14 @@ internal fun ColumnReader.toIcsEvent(
location = getString(EventExportProjection.IDX_LOCATION),
description = getString(EventExportProjection.IDX_DESCRIPTION),
reminderMinutes = if (isAllDay) {
val startDate = utcDate(dtStart)
val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate()
reminderMinutes.map {
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
}
} else {
reminderMinutes
},
status = eventStatus(EventExportProjection.IDX_STATUS),
status = status,
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
calendarName = calendarName,
)
@@ -97,7 +112,11 @@ internal fun exportExDates(
): List<String> {
if (exdate.isNullOrBlank()) return emptyList()
val zone = exdateListZone(exdate, eventTimezone)
return exdateStamps(exdate)
return exdate
.withoutExdateParameters()
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
.map { stamp ->
if (isAllDay) {
stamp.substringBefore('T')
@@ -4,7 +4,6 @@ import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import android.provider.CalendarContract
import android.util.Log
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse
private const val TAG = "InstanceMapper"
@@ -21,11 +20,14 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
return null
}
// Left blank when the event has no title: the placeholder is a display
// string, so it belongs to the UI layer where it can be localised (#321).
val title = getString(InstanceProjection.IDX_TITLE).orEmpty()
val rawTitle = getString(InstanceProjection.IDX_TITLE)
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
val color = getIntOrNull(InstanceProjection.IDX_EVENT_COLOR) ?: getInt(InstanceProjection.IDX_CALENDAR_COLOR)
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
} else {
getInt(InstanceProjection.IDX_EVENT_COLOR)
}
return EventInstance(
instanceId = getLong(InstanceProjection.IDX_INSTANCE_ID),
@@ -37,17 +39,7 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
color = color,
location = getString(InstanceProjection.IDX_LOCATION),
response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)),
isDeclined = getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS) ==
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
)
}
/**
* `SELF_ATTENDEE_STATUS` as the calendar surfaces read it: a tentative "maybe"
* counts as going, and everything that is not an open or refused invitation —
* your own events included — falls through to [EventResponse.Going].
*/
internal fun mapEventResponse(raw: Int): EventResponse = when (raw) {
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED -> EventResponse.Declined
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> EventResponse.Invited
else -> EventResponse.Going
}
@@ -1,16 +1,7 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.content.ContentUris
import android.net.Uri
import android.provider.CalendarContract
/** The `Instances` table over the window [beginMillis]..[endMillis]. */
internal fun instancesUri(beginMillis: Long, endMillis: Long): Uri =
CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
internal object CalendarProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Calendars._ID,
@@ -260,26 +251,40 @@ internal object MoveMasterProjection {
/**
* The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to
* carry them over to a moved copy (see [ExceptionRowSnapshot]). Both modified
* replay them against a moved copy (see [ExceptionRowSnapshot]). Both modified
* occurrences and cancellations (`STATUS_CANCELED`) are read; the query filters
* `DELETED = 0` so provider tombstones aren't carried over.
* `DELETED = 0` so provider tombstones aren't replayed.
*/
internal object ExceptionProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.ORIGINAL_INSTANCE_TIME,
CalendarContract.Events.STATUS,
CalendarContract.Events.TITLE,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.DURATION,
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.AVAILABILITY,
CalendarContract.Events.ACCESS_LEVEL,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.DESCRIPTION,
)
const val IDX_ID = 0
const val IDX_ORIGINAL_INSTANCE_TIME = 1
const val IDX_STATUS = 2
const val IDX_DTSTART = 3
const val IDX_DTEND = 4
const val IDX_DURATION = 5
const val IDX_TITLE = 3
const val IDX_ALL_DAY = 4
const val IDX_DTSTART = 5
const val IDX_DTEND = 6
const val IDX_DURATION = 7
const val IDX_EVENT_TIMEZONE = 8
const val IDX_AVAILABILITY = 9
const val IDX_ACCESS_LEVEL = 10
const val IDX_LOCATION = 11
const val IDX_DESCRIPTION = 12
}
internal object AttendeeProjection {
@@ -307,3 +312,8 @@ internal object ReminderProjection {
const val IDX_MINUTES = 0
const val IDX_METHOD = 1
}
internal object Fallbacks {
const val UNNAMED_CALENDAR = "(Unbenannter Kalender)"
const val UNTITLED_EVENT = "(Ohne Titel)"
}
@@ -1,7 +1,9 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
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
@@ -15,11 +17,18 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
// long-running birthdays/anniversaries still surface in search.
if (isNull(SearchProjection.IDX_DTSTART)) return null
val dtStart = getLong(SearchProjection.IDX_DTSTART)
val end = endMillis(dtStart, SearchProjection.IDX_DTEND, SearchProjection.IDX_DURATION)
.coerceAtLeast(dtStart)
val end = when {
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
}.coerceAtLeast(dtStart)
val title = getString(SearchProjection.IDX_TITLE).orEmpty()
val color = getIntOrNull(SearchProjection.IDX_EVENT_COLOR) ?: getInt(SearchProjection.IDX_CALENDAR_COLOR)
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(
@@ -34,6 +43,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
location = getString(SearchProjection.IDX_LOCATION),
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)),
isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) ==
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
)
}
@@ -2,15 +2,16 @@ package de.jeanlucmakiola.calendula.data.contacts
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.Event
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
import de.jeanlucmakiola.floret.android.hasPermission
import javax.inject.Inject
import javax.inject.Singleton
@@ -30,7 +31,8 @@ interface ContactSpecialDatesDataSource {
/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */
fun Context.hasContactsPermission(): Boolean =
hasPermission(Manifest.permission.READ_CONTACTS)
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
@@ -8,7 +8,6 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
@@ -213,8 +212,8 @@ class SpecialDatesSyncEngine @Inject constructor(
calendarId = calendarId,
title = title,
isAllDay = true,
start = FormTime(start),
end = FormTime(start),
start = start,
end = start,
reminders = reminderCtx.resolveAllDay(calendarId),
availability = Availability.Free,
rrule = YEARLY_RRULE,
@@ -20,10 +20,8 @@ import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
import de.jeanlucmakiola.calendula.data.time.AndroidTodaySource
import de.jeanlucmakiola.calendula.data.time.AndroidZoneSource
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton
private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore(
@@ -63,18 +61,6 @@ abstract class DataBindModule {
abstract fun bindSpecialDatesCalendarSpec(
impl: AndroidSpecialDatesCalendarSpec,
): SpecialDatesCalendarSpec
@Binds
@Singleton
abstract fun bindTodaySource(
impl: AndroidTodaySource,
): TodaySource
@Binds
@Singleton
abstract fun bindZoneSource(
impl: AndroidZoneSource,
): ZoneSource
}
@Module
@@ -85,4 +71,8 @@ object DataProvideModule {
@Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.calendulaDataStore
@Provides
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
}
@@ -0,0 +1,7 @@
package de.jeanlucmakiola.calendula.data.di
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
@@ -1,13 +1,15 @@
package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import de.jeanlucmakiola.floret.prefs.Pref
import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.nullableBooleanPref
import de.jeanlucmakiola.floret.prefs.nullableLongPref
import de.jeanlucmakiola.floret.prefs.nullableStringPref
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@@ -24,13 +26,18 @@ import javax.inject.Singleton
*/
@Singleton
class CalendarPrefs @Inject constructor(
store: DataStore<Preferences>,
private val store: DataStore<Preferences>,
) {
private val prefs = PrefStore(store)
val hiddenCalendarIds: Flow<Set<Long>> = prefs.flow(HIDDEN_IDS)
// Both id sets are deduped: the store is shared with SettingsPrefs, so any
// unrelated write would otherwise re-emit an identical set.
val hiddenCalendarIds: Flow<Set<Long>> = store.data
.map { prefs -> prefs[HIDDEN_IDS_KEY].parseIds() }
.distinctUntilChanged()
suspend fun setHiddenCalendarIds(ids: Set<Long>) = prefs.set(HIDDEN_IDS, ids)
suspend fun setHiddenCalendarIds(ids: Set<Long>) {
store.edit { prefs -> prefs.writeIds(HIDDEN_IDS_KEY, ids) }
}
/**
* Switch-offs the provider does not know about yet, because writing
@@ -41,18 +48,24 @@ class CalendarPrefs @Inject constructor(
* second visibility model: `CalendarVisibilityReconciler` drains it entry by
* entry as soon as the app may write, and nothing adds to it while it may.
*/
val pendingDisabledCalendarIds: Flow<Set<Long>> = prefs.flow(DISABLED_IDS)
val pendingDisabledCalendarIds: Flow<Set<Long>> = store.data
.map { prefs -> prefs[DISABLED_IDS_KEY].parseIds() }
.distinctUntilChanged()
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) {
prefs.update(DISABLED_IDS) { it + ids }
}
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) =
editPendingDisabled { it + ids }
/**
* Drop [ids] from the pending set, one at a time as the reconciler flushes
* them, so a run that fails part-way never re-applies what already landed.
*/
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) {
prefs.update(DISABLED_IDS) { it - ids.toSet() }
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) =
editPendingDisabled { it - ids.toSet() }
private suspend fun editPendingDisabled(transform: (Set<Long>) -> Set<Long>) {
store.edit { prefs ->
prefs.writeIds(DISABLED_IDS_KEY, transform(prefs[DISABLED_IDS_KEY].parseIds()))
}
}
/**
@@ -60,28 +73,39 @@ class CalendarPrefs @Inject constructor(
* owed. Null until the reconciler has evaluated it (which needs the calendar
* permission), false once it has been shown or was never needed.
*/
val visibilityNoticePending: Flow<Boolean?> = prefs.flow(VISIBILITY_NOTICE)
val visibilityNoticePending: Flow<Boolean?> = store.data.map { prefs ->
prefs[VISIBILITY_NOTICE_KEY]
}
suspend fun setVisibilityNoticePending(pending: Boolean) = prefs.set(VISIBILITY_NOTICE, pending)
suspend fun setVisibilityNoticePending(pending: Boolean) {
store.edit { prefs -> prefs[VISIBILITY_NOTICE_KEY] = pending }
}
/**
* The calendar the user last created an event in; preselected in the
* event form. Null until the first event is created.
*/
val lastUsedCalendarId: Flow<Long?> = prefs.flow(LAST_USED_CALENDAR)
val lastUsedCalendarId: Flow<Long?> = store.data.map { prefs ->
prefs[LAST_USED_CALENDAR_KEY]
}
suspend fun setLastUsedCalendarId(id: Long) = prefs.set(LAST_USED_CALENDAR, id)
suspend fun setLastUsedCalendarId(id: Long) {
store.edit { prefs -> prefs[LAST_USED_CALENDAR_KEY] = id }
}
private companion object {
val HIDDEN_IDS = idSetPref("hidden_calendar_ids")
val DISABLED_IDS = idSetPref("disabled_calendar_ids")
val VISIBILITY_NOTICE = nullableBooleanPref("visibility_notice_pending")
val LAST_USED_CALENDAR = nullableLongPref("last_used_calendar_id")
companion object {
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids")
internal val VISIBILITY_NOTICE_KEY = booleanPreferencesKey("visibility_notice_pending")
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
}
}
/** A comma-joined id set; non-numeric tokens are dropped, and an empty set removes the key. */
private fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
{ stored -> stored.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() }.toSet() },
{ ids -> if (ids.isEmpty()) null else ids.sorted().joinToString(",") },
)
private fun String?.parseIds(): Set<Long> = orEmpty()
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.toSet()
private fun MutablePreferences.writeIds(key: Preferences.Key<String>, ids: Set<Long>) {
if (ids.isEmpty()) remove(key) else set(key, ids.sorted().joinToString(","))
}
@@ -2,8 +2,10 @@ package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.nullableLongPref
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@@ -18,16 +20,17 @@ import javax.inject.Singleton
*/
@Singleton
class ReminderStatePrefs @Inject constructor(
store: DataStore<Preferences>,
private val store: DataStore<Preferences>,
) {
private val prefs = PrefStore(store)
/** The watermark, or `null` before the first scan has ever run. */
suspend fun lastScanMillis(): Long? = prefs.get(LAST_SCAN)
suspend fun lastScanMillis(): Long? = store.data.map { it[LAST_SCAN_KEY] }.first()
suspend fun setLastScanMillis(millis: Long) = prefs.set(LAST_SCAN, millis)
suspend fun setLastScanMillis(millis: Long) {
store.edit { prefs -> prefs[LAST_SCAN_KEY] = millis }
}
private companion object {
val LAST_SCAN = nullableLongPref("reminder_last_scan_millis")
val LAST_SCAN_KEY = longPreferencesKey("reminder_last_scan_millis")
}
}
File diff suppressed because it is too large Load Diff
@@ -6,8 +6,11 @@ import android.content.Intent
import androidx.core.net.toUri
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.android.goAsync
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
/**
@@ -41,17 +44,27 @@ class ReminderActionReceiver : BroadcastReceiver() {
// Cancel now so the notification doesn't linger until the alarm;
// the snooze delay read is the only async work.
notifier.cancel(alert)
goAsync {
val minutes = settingsPrefs.snoozeMinutes.first()
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
scheduler.schedule(alert, triggerAt)
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 -> {
goAsync {
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
notifier.post(alert)
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
notifier.post(alert)
}
} finally {
pendingResult.finish()
}
}
}
@@ -4,12 +4,20 @@ import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.android.setWakeup
import javax.inject.Inject
import javax.inject.Singleton
/**
* 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 31–32.
*/
internal fun AlarmManager.canScheduleExactCompat(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
/**
* Holds the app's own wake-up for the next reminder (#75). Exactly one alarm
* exists at a time, for the earliest reminder ahead; every firing re-scans and
@@ -23,7 +31,16 @@ class ReminderAlarmScheduler @Inject constructor(
fun scheduleScan(triggerAtMillis: Long) {
val alarmManager = context.getSystemService<AlarmManager>() ?: return
alarmManager.setWakeup(triggerAtMillis, scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
val pendingIntent = scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT)
if (alarmManager.canScheduleExactCompat()) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} else {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
}
}
/** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */
@@ -1,9 +1,9 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.Context
import android.content.ContentUris
import android.provider.CalendarContract
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.instancesUri
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
import javax.inject.Inject
import javax.inject.Singleton
@@ -37,7 +37,10 @@ class ProviderReminderInstanceSource @Inject constructor(
) : ReminderInstanceSource {
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
val uri = instancesUri(fromMillis, toMillis)
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, fromMillis)
ContentUris.appendId(this, toMillis)
}.build()
// `visible` is the flag the app's one visibility model writes (#75).
// The status clause mirrors CalendarDataSource.instances: NULL means
// "normal", so a bare `!= CANCELED` would drop every ordinary event.
@@ -5,10 +5,12 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
@@ -17,7 +19,6 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.floret.android.hasPermission
import kotlinx.coroutines.flow.first
import kotlinx.datetime.isoDayNumber
import java.time.DayOfWeek
@@ -47,7 +48,8 @@ class ReminderNotifier @Inject constructor(
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
fun canPost(): Boolean {
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
}
@@ -61,9 +63,13 @@ class ReminderNotifier @Inject constructor(
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
calendarDataSource.isCalendarVisible(calendarId) == false
/** Post [alert], unless its calendar is switched off. */
suspend fun post(alert: ReminderAlert) {
if (isSilenced(alert.calendarId)) return
/**
* Post [alert], unless its calendar is switched off. Returns whether the
* notification was put up, which the snooze re-show path uses to tell a
* silenced reminder from a delivered one.
*/
suspend fun post(alert: ReminderAlert): Boolean {
if (isSilenced(alert.calendarId)) return false
ensureChannel()
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
val is24Hour = settingsPrefs.timeFormat.first()
@@ -82,7 +88,6 @@ class ReminderNotifier @Inject constructor(
is24Hour = is24Hour,
today = Instant.now().atZone(zone).toLocalDate(),
firstDayOfWeek = firstDayOfWeek,
todayLabel = context.getString(R.string.reminder_day_today),
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
)
@@ -116,6 +121,8 @@ class ReminderNotifier @Inject constructor(
// POST_NOTIFICATIONS was revoked between canPost() and here.
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
}
// Handled either way — a retry hits the same revoked permission.
return true
}
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
@@ -2,18 +2,19 @@ package de.jeanlucmakiola.calendula.data.reminders
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.SupervisorJob
@@ -112,7 +113,9 @@ class ReminderScanner @Inject constructor(
alarms.scheduleScan(schedule.nextAlarmMillis)
}
private fun hasReadCalendar(): Boolean = context.hasPermission(Manifest.permission.READ_CALENDAR)
private fun hasReadCalendar(): Boolean = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
/**
* Re-scan when the provider changes, so a saved or deleted event re-arms the
@@ -4,7 +4,10 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.floret.android.goAsync
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@@ -26,7 +29,14 @@ class ReminderScheduleReceiver : BroadcastReceiver() {
// exported and the broadcasts it takes are protected, so any other
// action did not come from where it claims to.
if (intent.action !in HANDLED_ACTIONS) return
goAsync { scanner.scan() }
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
scanner.scan()
} finally {
pendingResult.finish()
}
}
}
companion object {
@@ -3,9 +3,9 @@ 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 de.jeanlucmakiola.floret.android.setWakeup
import javax.inject.Inject
import javax.inject.Singleton
@@ -31,6 +31,24 @@ class ReminderSnoozeScheduler @Inject constructor(
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
alarmManager.setWakeup(triggerAtMillis, pendingIntent)
if (canScheduleExact(alarmManager)) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} else {
// Exact alarms revoked (API 31–32); an inexact wake is the best
// available 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 31–32.
*/
private fun canScheduleExact(alarmManager: AlarmManager): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
}
@@ -27,12 +27,12 @@ import java.util.Locale
* alone would be ambiguous)
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
* already unambiguous)
* - all-day, one day: "Tomorrow" / "Thu" / "11 Jun 2026" (same relative rules,
* plus [todayLabel] — no time range to lean on)
* - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end)
* - all-day, one day: "11 Jun 2026"
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
*
* All-day instances store UTC midnights with an exclusive end, so they are read
* in UTC and the end day is the last *covered* day (issue #308).
* All-day instances already carry an explicit date, so they never gain a
* relative prefix. They store UTC midnights with an exclusive end, so they are
* read in UTC and the end day is the last *covered* day.
*/
fun reminderTimeText(
beginMillis: Long,
@@ -43,7 +43,6 @@ fun reminderTimeText(
is24Hour: Boolean,
today: LocalDate,
firstDayOfWeek: DayOfWeek,
todayLabel: String,
tomorrowLabel: String,
yesterdayLabel: String,
): String {
@@ -54,16 +53,11 @@ fun reminderTimeText(
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
.minusDays(1)
.coerceAtLeast(firstDay)
val start = relativeDayPrefix(
day = firstDay,
today = today,
firstDayOfWeek = firstDayOfWeek,
locale = locale,
dateFormat = dateFormat,
tomorrowLabel = tomorrowLabel,
yesterdayLabel = yesterdayLabel,
) ?: todayLabel
return if (lastDay == firstDay) start else start + RANGE + dateFormat.format(lastDay)
return if (lastDay == firstDay) {
dateFormat.format(firstDay)
} else {
dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay)
}
}
val timeFormat = timeOfDayFormatter(is24Hour, locale)
@@ -1,67 +0,0 @@
package de.jeanlucmakiola.calendula.data.time
import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.android.broadcastTicks
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transformLatest
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import javax.inject.Inject
import kotlin.time.Clock
/** The current local date, re-emitted whenever it changes (#373). */
interface TodaySource {
val today: Flow<LocalDate>
}
/**
* Re-reads the date on every minute boundary while collected, and at once on the
* system's date, time and zone broadcasts, which can arrive late or not at all.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AndroidTodaySource @Inject constructor(
@ApplicationContext private val context: Context,
) : TodaySource {
override val today: Flow<LocalDate> = context
.broadcastTicks(Intent.ACTION_DATE_CHANGED, Intent.ACTION_TIME_CHANGED, Intent.ACTION_TIMEZONE_CHANGED)
.transformLatest {
while (true) {
emit(currentDate())
delay(MINUTE_MILLIS - Clock.System.now().toEpochMilliseconds() % MINUTE_MILLIS)
}
}
.distinctUntilChanged()
}
private const val MINUTE_MILLIS = 60_000L
fun currentDate(): LocalDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
/**
* Today as a hot flow seeded with [initial]. [onRollover] runs with the previous
* and the new date each time the day changes while collected, so a screen left
* open over midnight can move off yesterday (#373).
*/
fun Flow<LocalDate>.stateFollowingDays(
scope: CoroutineScope,
initial: LocalDate,
onRollover: (old: LocalDate, new: LocalDate) -> Unit,
): StateFlow<LocalDate> {
var last = initial
return onEach { date ->
val old = last
last = date
if (old != date) onRollover(old, date)
}.stateWhileSubscribed(scope, initial)
}
@@ -1,36 +0,0 @@
package de.jeanlucmakiola.calendula.data.time
import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.android.broadcastTicks
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.datetime.TimeZone
import javax.inject.Inject
/**
* The zone the device is in. Every screen that turns wall clock into instants
* reads it from here, so the timeline, the event form and a saved move use the
* same zone (#387).
*/
interface ZoneSource {
/** The device's zone, re-emitted whenever it changes. */
val zone: Flow<TimeZone>
/** The device's zone right now. */
fun current(): TimeZone
}
/** Follows the system's zone broadcast. */
class AndroidZoneSource @Inject constructor(
@ApplicationContext private val context: Context,
) : ZoneSource {
override val zone: Flow<TimeZone> = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED)
.map { current() }
.distinctUntilChanged()
override fun current(): TimeZone = TimeZone.currentSystemDefault()
}
@@ -46,19 +46,3 @@ fun hasSystemHiddenCalendars(
calendars: List<CalendarSource>,
pendingDisabledIds: Set<Long>,
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
/**
* The failure a calendar list implies, or null when there is something to show.
* Distinguishes "the device has no calendars" from "every one of them is
* switched off" (#239) — both leave the grid empty, but only the second is
* fixed from Settings → Calendars.
*
* The filter sheet's own hidden set is deliberately not folded in: emptying it
* is a deliberate act taken moments earlier, in a sheet that is still one tap
* away.
*/
fun calendarListFailure(calendars: List<CalendarSource>): FailureReason? = when {
calendars.isEmpty() -> FailureReason.NoCalendarsConfigured
calendars.none { it.isVisibleInSystem } -> FailureReason.AllCalendarsHidden
else -> null
}
@@ -1,18 +0,0 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.TimeZone
/** Lower values appear first in lists and get the upper visible lanes in grids. */
fun EventInstance.displayPriority(zone: TimeZone): Int = when {
isDeclined -> 3
spansMultipleDays(zone) -> 0
isAllDay -> 1
else -> 2
}
/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */
fun eventDisplayComparator(zone: TimeZone): Comparator<EventInstance> =
compareBy<EventInstance> { it.displayPriority(zone) }
.thenBy { it.start }
.thenBy { it.title }
.thenBy { it.instanceId }
@@ -4,7 +4,7 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
@@ -17,8 +17,8 @@ data class EventForm(
val calendarId: Long?,
val title: String = "",
val isAllDay: Boolean = false,
val start: FormTime,
val end: FormTime,
val start: LocalDateTime,
val end: LocalDateTime,
/**
* The zone [start]/[end] are wall-clock values in, or null to follow the
* device — null is not "no zone", it is "whichever zone the device is in
@@ -70,19 +70,6 @@ data class EventForm(
val attendees: List<EventAttendee> = emptyList(),
)
/**
* One end of a form's span: a wall-clock [dateTime] in the form's zone, and the
* [offset] it was read at when the fall-back hour repeats that wall clock — both
* of the hour's instants share one [LocalDateTime] (#387). Null everywhere else,
* so a time changed and changed back compares equal again. A time picked by hand
* gets its pass from [repicked]; one without an offset reads as the earlier
* pass. One value, so a time never changes without its pass. See [toFormTime].
*/
data class FormTime(val dateTime: LocalDateTime, val offset: UtcOffset? = null) {
val date: LocalDate get() = dateTime.date
val time: LocalTime get() = dateTime.time
}
/**
* 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
@@ -148,23 +135,26 @@ enum class EventFormProblem {
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
// All-day events are date-anchored and carry a nominal "UTC" that is an
// anchor, not a location, so they never pin a zone.
val pinned = if (instance.isAllDay) {
val pinnedZone = if (instance.isAllDay) {
null
} else {
eventTimezone
?.takeIf { it != zone.id }
// An unparseable id (a malformed sync row) can't be honoured or
// shown; fall back to the device zone rather than failing the open.
?.let { id -> zoneOrNull(id)?.let { id to it } }
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
}
val pinnedZone = pinned?.first
val formZone = pinned?.second ?: zone
val begin = Instant.fromEpochMilliseconds(beginMillis)
val finish = Instant.fromEpochMilliseconds(endMillis)
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
val (start, end) = if (instance.isAllDay) {
allDayFormTimes(begin.toLocalDateTime(TimeZone.UTC).date, finish.toLocalDateTime(TimeZone.UTC).date)
val startDate = Instant.fromEpochMilliseconds(beginMillis)
.toLocalDateTime(TimeZone.UTC).date
val endExclusive = Instant.fromEpochMilliseconds(endMillis)
.toLocalDateTime(TimeZone.UTC).date
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
} else {
begin.toFormTime(formZone) to finish.toFormTime(formZone)
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
}
return EventForm(
calendarId = instance.calendarId,
@@ -178,7 +168,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
reminders = reminders.map { it.minutes }.distinct().sorted(),
availability = availability,
accessLevel = accessLevel,
rrule = rrule.normalizedRrule(),
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
// The provider fills EVENT_COLOR from the key, so [color] is the
// swatch either way; a null colour means the event inherits its
// calendar's colour.
@@ -231,30 +221,20 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
)
/**
* Form times for an all-day span stored as UTC midnights with an exclusive
* end: the last covered day (never before [start]), with placeholder
* wall-clock times kept in case the user switches the event to timed.
*/
internal fun allDayFormTimes(start: LocalDate, endExclusive: LocalDate?): Pair<FormTime, FormTime> {
val end = endExclusive?.let { maxOf(start, LocalDate.fromEpochDays(it.toEpochDays() - 1)) } ?: start
return FormTime(LocalDateTime(start, LocalTime(9, 0))) to FormTime(LocalDateTime(end, LocalTime(10, 0)))
}
/** The bare RRULE value the form edits; blank means none. */
internal fun String?.normalizedRrule(): String? = this?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }
/**
* Whether this form's times mean something else in [target] — a timed event
* pinned to another zone. False when there's nothing to disambiguate: an
* unpinned event (already in [target]), one pinned to [target] itself, an
* all-day event (no zone), or an unparseable pinned zone.
* The form's times as they land in [target] — what a pinned event's wall-clock
* actually means where the user is standing. Null when there's nothing to
* disambiguate: an unpinned event (already in [target]), one pinned to [target]
* itself, an all-day event (no zone), or an unparseable pinned zone.
*
* The form edits a pinned event in its own zone, so this is what tells the UI
* to show the other side of the pair rather than making the user do the arithmetic.
* The form edits a pinned event in its own zone, so this is what lets the UI
* show the other side of the pair rather than making the user do the arithmetic.
*/
fun EventForm.isPinnedAwayFrom(target: TimeZone): Boolean {
if (isAllDay) return false
return resolvedZone(target).id != target.id
fun EventForm.timesIn(target: TimeZone): Pair<LocalDateTime, LocalDateTime>? {
if (isAllDay) return null
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return null
if (pinned.id == target.id) return null
return start.toInstant(pinned).toLocalDateTime(target) to
end.toInstant(pinned).toLocalDateTime(target)
}
/**
@@ -274,19 +254,9 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
}
/**
* What stops this form from saving. A timed span is checked as instants in the
* form's zone (else [deviceZone]): one ending in the fall-back hour's repeat can
* end on an earlier wall clock than it starts (#387).
*/
fun EventForm.problems(deviceZone: TimeZone): Set<EventFormProblem> = buildSet {
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
if (calendarId == null) add(EventFormProblem.NoCalendar)
val endsTooEarly = if (isAllDay) {
end.date < start.date
} else {
val zone = resolvedZone(deviceZone)
endInstant(zone) < startInstant(zone)
}
val endsTooEarly = if (isAllDay) end.date < start.date else end < start
if (endsTooEarly) add(EventFormProblem.EndBeforeStart)
// An UNTIL before the first day would make the provider generate zero
// occurrences — the event would silently vanish from every view.
@@ -295,20 +265,3 @@ fun EventForm.problems(deviceZone: TimeZone): Set<EventFormProblem> = buildSet {
add(EventFormProblem.RecurrenceEndsBeforeStart)
}
}
/**
* A create form seeded from this occurrence, to save a copy as a new,
* independent event (#52). The recurrence is dropped — a duplicate is a single
* event. The source calendar is kept only when [canModify], otherwise the id is
* cleared so the create resolves to a writable calendar, which lets a read-only
* event be copied into an editable one. [instance]'s own times carry over.
*/
fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
toEditForm(
beginMillis = instance.start.toEpochMilliseconds(),
endMillis = instance.end.toEpochMilliseconds(),
zone = zone,
).copy(
rrule = null,
calendarId = instance.calendarId.takeIf { canModify },
)
@@ -1,8 +1,8 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.number
import kotlinx.datetime.plus
@@ -101,13 +101,19 @@ object EventSearch {
val today = todayStart.toLocalDateTime(zone).date
val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) }
val (upcoming, past) = matched.partition { it.event.spanLastDay(zone) >= today }
// What is left reads forwards; what is behind us reads backwards from
// today, nearest first.
val ordered = upcoming.sortedBy { it.event.dayStart(zone) } +
past.sortedByDescending { it.event.dayStart(zone) }
val upcoming = { m: Matched -> m.event.spanLastDay(zone) >= today }
val order = compareBy<Matched> { if (upcoming(it)) 0 else 1 }
.thenComparator { a, b ->
// What is left reads forwards; what is behind us reads backwards
// from today, nearest first.
if (upcoming(a)) {
a.event.dayStart(zone).compareTo(b.event.dayStart(zone))
} else {
b.event.dayStart(zone).compareTo(a.event.dayStart(zone))
}
}
val (inDescriptions, dated) = ordered.partition { it.descriptionOnly }
val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly }
return SearchResults(
months = groupByMonth(dated, zone, todayStart),
inDescriptions = inDescriptions.map { it.hit },
@@ -120,18 +126,33 @@ object EventSearch {
zone: TimeZone,
todayStart: Instant,
): List<SearchMonth> {
val current = todayStart.toLocalDateTime(zone).date.let { YearMonth(it.year, it.month) }
val groups = dated.groupBy { m -> m.event.spanFirstDay(zone).let { YearMonth(it.year, it.month) } }
val (behind, ahead) = groups.keys.partition { it < current }
return (ahead.sorted() + behind.sortedDescending()).map { month ->
SearchMonth(
year = month.year,
monthNumber = month.month.number,
hits = groups.getValue(month).map { it.hit },
val currentKey = monthKey(todayStart.toLocalDateTime(zone).date)
return dated
.groupBy { monthKey(it.event.spanFirstDay(zone)) }
.entries
.sortedWith(
compareBy<Map.Entry<Int, List<Matched>>> {
when {
it.key == currentKey -> 0
it.key > currentKey -> 1
else -> 2
}
}.thenComparator { a, b ->
if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key)
},
)
}
.map { (key, entries) ->
SearchMonth(
year = key / 12,
monthNumber = key % 12 + 1,
hits = entries.map { it.hit },
)
}
}
/** Year and month as one comparable number. */
private fun monthKey(date: LocalDate): Int = date.year * 12 + (date.month.number - 1)
private fun match(
candidate: SearchCandidate,
tokens: List<String>,
@@ -141,20 +162,16 @@ object EventSearch {
val title = candidate.title?.takeIf { it.isNotBlank() }
val location = candidate.event.location?.takeIf { it.isNotBlank() }
// Collapsed so a multi-line description matches the way it is drawn.
val rich = candidate.description?.let(::descriptionRichText)
val description = rich?.text
val description = candidate.description
?.replace(WHITESPACE, " ")
?.trim()
?.takeIf { it.isNotEmpty() }
// A link's address isn't in the drawn text, but "zoom.us" should still find the invite.
val linkUrls = rich?.links.orEmpty().map { it.url }
// Mid-word counts: "termin" has to keep finding "Zahnarzttermin".
val present = { token: String ->
title?.contains(token, ignoreCase = true) == true ||
location?.contains(token, ignoreCase = true) == true ||
description?.contains(token, ignoreCase = true) == true ||
linkUrls.any { it.contains(token, ignoreCase = true) }
description?.contains(token, ignoreCase = true) == true
}
if (!tokens.all(present)) return null
@@ -3,137 +3,17 @@ package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.offsetAt
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toJavaLocalDateTime
import kotlinx.datetime.toJavaZoneId
import kotlinx.datetime.toJavaZoneOffset
import kotlinx.datetime.toKotlinUtcOffset
import kotlinx.datetime.toLocalDateTime
import java.time.ZonedDateTime
import kotlin.time.Instant
import kotlin.time.toJavaInstant
import kotlin.time.toKotlinInstant
/** [id] as a zone, or null when the tz database doesn't know it. */
internal fun zoneOrNull(id: String): TimeZone? = runCatching { TimeZone.of(id) }.getOrNull()
/**
* The zone this form's wall-clock times mean, matching what the data layer
* resolves them in at write time: the form's own pinned zone, else [deviceZone].
*/
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
timezone?.let(::zoneOrNull) ?: deviceZone
/**
* This wall-clock time in [zone], at [preferred] when the fall-back hour repeats
* it and that is one of its two offsets — otherwise the earlier one (#387).
*/
fun LocalDateTime.resolveIn(zone: TimeZone, preferred: UtcOffset?): Instant =
ZonedDateTime.ofLocal(toJavaLocalDateTime(), zone.toJavaZoneId(), preferred?.toJavaZoneOffset())
.toInstant().toKotlinInstant()
/** Whether this instant's wall clock in [zone] is one the fall-back hour repeats. */
fun Instant.inRepeatedHour(zone: TimeZone): Boolean {
val zid = zone.toJavaZoneId()
return zid.rules.getTransition(java.time.LocalDateTime.ofInstant(toJavaInstant(), zid))?.isOverlap == true
}
/**
* [at]'s offset in this zone when its wall clock is one the fall-back hour
* repeats, else null: elsewhere the wall clock alone already names the instant.
* What a [FormTime.offset] holds (#387).
*/
fun TimeZone.repeatedHourOffsetAt(at: Instant): UtcOffset? = if (at.inRepeatedHour(this)) offsetAt(at) else null
/** This instant as a [FormTime] in [zone], keeping its pass of a repeated hour. */
fun Instant.toFormTime(zone: TimeZone): FormTime = FormTime(toLocalDateTime(zone), zone.repeatedHourOffsetAt(this))
/** This time as an instant in [zone], on its kept pass of a repeated hour. */
fun FormTime.instantIn(zone: TimeZone): Instant = dateTime.resolveIn(zone, offset)
/**
* This edge of an event moved to [picked] by hand — a picker or a resize handle —
* as a [FormTime] in [zone]. A time in the spring-forward gap moves on to the
* real one, as the provider would read it. A wall clock the fall-back hour
* repeats has two instants; which one it means (#387):
* - [original]'s, when [picked] is the wall clock the event was opened with, so
* a time changed and changed back is the time it was;
* - else the one that moves the edge in the direction the user moved it:
* earlier than this edge's instant when [picked] is before [shownAt] (where
* the edge was on screen), later when after;
* - kept within [notBefore]..[notAfter] (the other edge), and to the ones
* [drawable] accepts — where the screen can show it — when any fit.
*/
fun FormTime.repicked(
picked: LocalDateTime,
zone: TimeZone,
shownAt: LocalDateTime = dateTime,
original: FormTime? = null,
notBefore: Instant? = null,
notAfter: Instant? = null,
drawable: (Instant) -> Boolean = { true },
): FormTime {
if (picked == dateTime && picked == shownAt) return this
val offsets = zone.toJavaZoneId().rules.getValidOffsets(picked.toJavaLocalDateTime())
if (offsets.isEmpty()) return FormTime(picked).instantIn(zone).toFormTime(zone)
if (offsets.size == 1) return FormTime(picked)
val passes = offsets.map { picked.toInstant(it.toKotlinUtcOffset()) }.sorted()
val bounded = passes.filter { (notBefore == null || it >= notBefore) && (notAfter == null || it <= notAfter) }
.ifEmpty { passes }
val fitting = bounded.filter(drawable).ifEmpty { bounded }
if (original?.dateTime == picked && original.offset != null && original.instantIn(zone) in fitting) return original
val from = instantIn(zone)
val chosen = when {
picked < shownAt -> fitting.lastOrNull { it < from }
picked > shownAt -> fitting.firstOrNull { it > from }
else -> null
} ?: fitting.minBy { (it - from).absoluteValue }
return chosen.toFormTime(zone)
}
/** The form's start as an instant in [zone]. */
fun EventForm.startInstant(zone: TimeZone): Instant = start.instantIn(zone)
/** The form's end as an instant in [zone]. */
fun EventForm.endInstant(zone: TimeZone): Instant = end.instantIn(zone)
/** The form's timed span set to these instants, read in [zone]. */
fun EventForm.withTimes(start: Instant, end: Instant, zone: TimeZone): EventForm =
copy(start = start.toFormTime(zone), end = end.toFormTime(zone))
/**
* The form pinned to [zoneId] (null: the device). A kept pass of a repeated hour
* was one of the old zone's, so it goes when the zone the times mean changes.
*/
fun EventForm.withTimezone(zoneId: String?, deviceZone: TimeZone): EventForm {
val pinned = copy(timezone = zoneId)
if (pinned.resolvedZone(deviceZone) == resolvedZone(deviceZone)) return pinned
return pinned.withoutKeptPasses()
}
/**
* The form with its start picked by hand, keeping its length. A repeated hour's
* pass is [repicked]: [original] is the start the event was opened with.
*/
fun EventForm.withStart(start: LocalDateTime, zone: TimeZone, original: FormTime? = null): EventForm {
val span = endInstant(zone) - startInstant(zone)
val startAt = this.start.repicked(start, zone, original = original).instantIn(zone)
return withTimes(startAt, startAt + span, zone)
}
/**
* The form with its end picked by hand. A repeated hour's pass is [repicked],
* kept after the start where it can be: [original] is the end the event was
* opened with.
*/
fun EventForm.withEnd(end: LocalDateTime, zone: TimeZone, original: FormTime? = null): EventForm =
copy(end = this.end.repicked(end, zone, original = original, notBefore = startInstant(zone)))
/** The form with no kept passes: its times as the wall clock alone says them. */
fun EventForm.withoutKeptPasses(): EventForm = copy(start = FormTime(start.dateTime), end = FormTime(end.dateTime))
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
/**
* The form moved so it starts at [newStart], keeping its **instant** duration —
@@ -146,8 +26,11 @@ fun EventForm.withoutKeptPasses(): EventForm = copy(start = FormTime(start.dateT
fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
if (isAllDay) return this
val zone = resolvedZone(deviceZone)
val span = endInstant(zone) - startInstant(zone)
return withTimes(newStart, newStart + span, zone)
val span = end.toInstant(zone) - start.toInstant(zone)
return copy(
start = newStart.toLocalDateTime(zone),
end = (newStart + span).toLocalDateTime(zone),
)
}
/**
@@ -160,21 +43,11 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
if (isAllDay) {
return copy(
start = FormTime(newStart),
end = FormTime(LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time)),
start = newStart,
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time),
)
}
val zone = resolvedZone(deviceZone)
val span = endInstant(zone) - startInstant(zone)
val startAt = FormTime(newStart, start.offset).instantIn(zone)
// The start keeps the wall clock it was moved to, even one the spring-forward
// gap skips, so a recurring series still sees a whole-day move.
val movedStart = startAt.toFormTime(zone).takeIf { it.dateTime == newStart } ?: FormTime(newStart)
return copy(start = movedStart, end = (startAt + span).toFormTime(zone))
}
/** The form with new start and end, each in the form's own zone — a resize. All-day is untouched. */
fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm {
if (isAllDay) return this
return withTimes(newStart, newEnd, resolvedZone(deviceZone))
val span = end.toInstant(zone) - start.toInstant(zone)
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
}
@@ -1,387 +0,0 @@
package de.jeanlucmakiola.calendula.domain
/** Inline formatting an HTML description keeps; fonts, sizes and colours are dropped. */
enum class InlineStyle { Bold, Italic, Underline, Strikethrough, Superscript, Subscript, Monospace }
data class StyleRange(val start: Int, val end: Int, val style: InlineStyle)
/** A link from an `<a href>`, spanning [start] until [end] (exclusive). */
data class LinkRange(val start: Int, val end: Int, val url: String)
/** Text as drawn, with the formatting and links that sit on it. Plain text has neither. */
data class RichText(
val text: String,
val styles: List<StyleRange> = emptyList(),
val links: List<LinkRange> = emptyList(),
) {
/**
* [text] with each link's address after it, where the text doesn't already
* show it, so a copied "Join the meeting" still carries its URL.
*/
fun copyText(): String {
if (links.isEmpty()) return text
val out = StringBuilder(text)
for (link in links.sortedByDescending { it.end }) {
val address = link.url.removePrefix("mailto:").removePrefix("tel:")
if (address !in text.substring(link.start, link.end)) out.insert(link.end, " ($address)")
}
return out.toString()
}
}
fun looksLikeHtml(text: String): Boolean = htmlTagRegex.containsMatchIn(text)
/** [description] as it reads: HTML rendered down to [RichText], plain text as it is. */
fun descriptionRichText(description: String): RichText =
if (looksLikeHtml(description)) parseHtml(description) else RichText(description)
private val BLOCK_TAGS = setOf(
"p", "div", "ul", "ol", "li", "table", "tr", "blockquote", "pre", "hr", "section", "article",
"header", "footer", "dl", "dt", "dd", "address", "center", "h1", "h2", "h3", "h4", "h5", "h6",
)
// Their content is never text to show.
private val SKIPPED_TAGS = setOf("head", "style", "script", "title", "xml", "template")
private val STYLE_TAGS = mapOf(
"b" to InlineStyle.Bold, "strong" to InlineStyle.Bold, "th" to InlineStyle.Bold,
"h1" to InlineStyle.Bold, "h2" to InlineStyle.Bold, "h3" to InlineStyle.Bold,
"h4" to InlineStyle.Bold, "h5" to InlineStyle.Bold, "h6" to InlineStyle.Bold,
"i" to InlineStyle.Italic, "em" to InlineStyle.Italic, "cite" to InlineStyle.Italic,
"u" to InlineStyle.Underline, "ins" to InlineStyle.Underline,
"s" to InlineStyle.Strikethrough, "strike" to InlineStyle.Strikethrough, "del" to InlineStyle.Strikethrough,
"sup" to InlineStyle.Superscript, "sub" to InlineStyle.Subscript,
"code" to InlineStyle.Monospace, "tt" to InlineStyle.Monospace, "pre" to InlineStyle.Monospace,
)
// A real tag the renderer knows, not prose like "<3" or "a < b". Google
// Calendar and Exchange both store their descriptions as HTML.
private val htmlTagRegex = Regex(
(BLOCK_TAGS + SKIPPED_TAGS + STYLE_TAGS.keys + setOf("html", "body", "br", "span", "a", "font", "img", "meta", "td"))
.joinToString("|", prefix = """</?(?:""", postfix = """)(?:\s[^<>]*)?/?>"""),
RegexOption.IGNORE_CASE,
)
private val LINK_SCHEMES = listOf("http://", "https://", "mailto:", "tel:")
private const val NBSP = '\u00A0'
private const val CELL_SEPARATOR = " · "
/**
* Render [html] down to text with its inline formatting and links. Blocks and
* `<br>` become line breaks, list items get a bullet or number, table rows a
* line each with their cells side by side. Lenient about broken markup:
* whatever doesn't parse as a tag stays text.
*/
fun parseHtml(html: String): RichText = HtmlRenderer(html).render()
// Markup that sets its own lines. Without any, the text's newlines are the
// lines: "Agenda:\n1. Intro" with one <a href> in it stays on two lines.
private val lineTagRegex = Regex("""<(?:br|p|div|li|tr|h[1-6]|pre|blockquote|table)\b""", RegexOption.IGNORE_CASE)
private class HtmlRenderer(private val html: String) {
private val out = StringBuilder()
private val styles = mutableListOf<StyleRange>()
private val links = mutableListOf<LinkRange>()
private val openStyles = mutableListOf<Pair<String, Int>>()
private var openLink: Pair<String, Int>? = null
private val lists = mutableListOf<ListState>()
private val keepNewlines = !lineTagRegex.containsMatchIn(html)
private var preDepth = 0
private var rowStart = 0
private var cellNeedsSeparator = false
// Where the current list item's bullet ends, so a <p> inside the item
// doesn't push its text off the bullet's line.
private var markerEnd = -1
private class ListState(val ordered: Boolean, var count: Int = 0)
fun render(): RichText {
var i = 0
while (i < html.length) {
val lt = html.indexOf('<', i)
if (lt < 0) {
text(html.substring(i))
break
}
if (lt > i) text(html.substring(i, lt))
i = tag(lt)
}
return normalize()
}
/** Handle the markup at [lt]; returns where reading continues. */
private fun tag(lt: Int): Int {
if (html.startsWith("<!--", lt)) {
val end = html.indexOf("-->", lt + 4)
return if (end < 0) html.length else end + 3
}
if (html.startsWith("<!", lt) || html.startsWith("<?", lt)) {
val end = html.indexOf('>', lt)
return if (end < 0) html.length else end + 1
}
val closing = html.getOrNull(lt + 1) == '/'
val nameStart = if (closing) lt + 2 else lt + 1
if (html.getOrNull(nameStart)?.isLetter() != true) {
text("<")
return lt + 1
}
val end = tagEnd(lt)
if (end < 0) {
text("<")
return lt + 1
}
var nameEnd = nameStart
while (nameEnd < end && (html[nameEnd].isLetterOrDigit() || html[nameEnd] == ':')) nameEnd++
val name = html.substring(nameStart, nameEnd).lowercase()
val attrs = html.substring(nameEnd, end)
if (closing) {
close(name)
} else if (name in SKIPPED_TAGS && !attrs.trimEnd().endsWith("/")) {
// Never closed (an omitted </head> is valid): just ignore the tag.
val closeAt = html.indexOf("</$name", end, ignoreCase = true)
if (closeAt < 0) return end + 1
val closeEnd = html.indexOf('>', closeAt)
return if (closeEnd < 0) html.length else closeEnd + 1
} else {
open(name, attrs)
}
return end + 1
}
/** The `>` closing the tag at [lt], skipping any inside quoted attributes. */
private fun tagEnd(lt: Int): Int {
var quote: Char? = null
for (j in lt + 1 until html.length) {
val c = html[j]
when {
quote != null -> if (c == quote) quote = null
c == '"' || c == '\'' -> quote = c
c == '>' -> return j
c == '<' -> return -1
}
}
return -1
}
private fun open(name: String, attrs: String) {
when (name) {
"br" -> newline()
"li" -> {
markerEnd = -1
cellNeedsSeparator = false
lineBreak()
val list = lists.lastOrNull()
out.append("$NBSP$NBSP".repeat((lists.size - 1).coerceAtLeast(0)))
if (list?.ordered == true) {
list.count++
out.append("${list.count}. ")
} else {
out.append("• ")
}
markerEnd = out.length
}
"ul", "ol" -> {
cellNeedsSeparator = false
lineBreak()
val start = startRegex.find(attrs)?.groupValues?.get(1)?.toIntOrNull() ?: 1
lists += ListState(ordered = name == "ol", count = start - 1)
}
"tr" -> {
lineBreak()
rowStart = out.length
cellNeedsSeparator = false
}
"td", "th" -> {
// An empty spacer cell leaves nothing behind, not even its &nbsp;.
if (rowStart <= out.length && (rowStart until out.length).none { out[it].isContent() }) {
cut(rowStart)
} else {
cellNeedsSeparator = true
}
}
"a" -> {
closeLink()
hrefRegex.find(attrs)
?.groupValues?.drop(1)?.firstOrNull { it.isNotEmpty() }
?.let(::decodeEntities)
?.trim()
?.let { href ->
val scheme = LINK_SCHEMES.firstOrNull { href.startsWith(it, ignoreCase = true) }
?: return@let
// Intent filters match the scheme case-sensitively.
openLink = scheme + href.substring(scheme.length) to out.length
}
}
else -> if (name in BLOCK_TAGS) lineBreak()
}
if (name == "pre") preDepth++
STYLE_TAGS[name]?.let { openStyles += name to out.length }
}
private fun close(name: String) {
// A row's last cell, if empty, leaves a separator pending; it ends with the row.
if (name == "tr" || name == "table") cellNeedsSeparator = false
val index = openStyles.indexOfLast { it.first == name }
if (index >= 0) {
val (_, start) = openStyles.removeAt(index)
if (out.length > start) styles += StyleRange(start, out.length, STYLE_TAGS.getValue(name))
}
if (name == "pre") preDepth = (preDepth - 1).coerceAtLeast(0)
if (name == "li" || name == "ul" || name == "ol") {
if (out.length == markerEnd) cut(out.lastIndexOf("\n") + 1)
markerEnd = -1
}
when (name) {
"ul", "ol" -> {
lists.removeLastOrNull()
lineBreak()
}
"a" -> closeLink()
else -> if (name in BLOCK_TAGS) lineBreak()
}
}
private fun closeLink() {
val (url, start) = openLink ?: return
if (out.length > start) links += LinkRange(start, out.length, url)
openLink = null
}
private fun text(raw: String) {
val decoded = decodeEntities(raw)
if (preDepth > 0) {
out.append(decoded.replace("\r\n", "\n"))
return
}
for (c in decoded) {
if (c == '\n' && keepNewlines) {
newline()
} else if (c == ' ' || c == '\n' || c == '\t' || c == '\r' || c == '\u000C') {
if (out.isNotEmpty() && out.last() != ' ' && out.last() != '\n') out.append(' ')
} else {
if (cellNeedsSeparator && c.isContent()) separateCell()
out.append(c)
}
}
}
/** Put this cell's text beside the row's last, even after a block in that cell ended its line. */
private fun separateCell() {
cellNeedsSeparator = false
var end = out.length
while (end > rowStart && !out[end - 1].isContent()) end--
cut(end)
out.append(CELL_SEPARATOR)
openStyles.replaceAll { (name, start) -> name to if (start >= end) out.length else start }
openLink = openLink?.let { (url, start) -> url to if (start >= end) out.length else start }
}
private fun Char.isContent() = !isWhitespace() && this != NBSP
private fun newline() {
trimTrailingSpaces()
out.append('\n')
}
/** End the current line, unless it's already ended or holds just a bullet. */
private fun lineBreak() {
if (out.length == markerEnd) return
trimTrailingSpaces()
if (out.isNotEmpty() && out.last() != '\n') out.append('\n')
}
private fun trimTrailingSpaces() {
while (out.isNotEmpty() && out.last() == ' ') out.setLength(out.length - 1)
}
/** Drop the output from [length] on, with whatever formatting lay only there. */
private fun cut(length: Int) {
if (length >= out.length) return
out.setLength(length)
styles.replaceAll { it.copy(end = minOf(it.end, length)) }
styles.removeAll { it.end <= it.start }
links.replaceAll { it.copy(end = minOf(it.end, length)) }
links.removeAll { it.end <= it.start }
openStyles.replaceAll { (name, start) -> name to minOf(start, length) }
openLink = openLink?.let { (url, start) -> url to minOf(start, length) }
}
/**
* Tidy the rendered text: no-break spaces become plain, lines lose trailing
* blanks, runs of empty lines shrink to one and the ends are trimmed. Spans
* move with the text they cover.
*/
private fun normalize(): RichText {
closeLink()
for ((name, start) in openStyles) {
if (out.length > start) styles += StyleRange(start, out.length, STYLE_TAGS.getValue(name))
}
val src = out.toString().replace(NBSP, ' ')
val map = IntArray(src.length + 1)
val result = StringBuilder()
var i = 0
while (i < src.length) {
var lineEnd = src.indexOf('\n', i)
if (lineEnd < 0) lineEnd = src.length
var contentEnd = lineEnd
while (contentEnd > i && src[contentEnd - 1].isWhitespace()) contentEnd--
for (j in i until lineEnd) {
map[j] = result.length
if (j < contentEnd) result.append(src[j])
}
if (lineEnd < src.length) {
map[lineEnd] = result.length
if (!result.endsWith("\n\n")) result.append('\n')
}
i = lineEnd + 1
}
map[src.length] = result.length
val leading = result.length - result.trimStart().length
val text = result.trim().toString()
fun at(index: Int) = (map[index] - leading).coerceIn(0, text.length)
return RichText(
text = text,
styles = styles.mapNotNull { r -> StyleRange(at(r.start), at(r.end), r.style).takeIf { it.end > it.start } },
links = links.mapNotNull { r -> LinkRange(at(r.start), at(r.end), r.url).takeIf { it.end > it.start } },
)
}
}
private val startRegex = Regex("""(?:^|\s)start\s*=\s*["']?(-?\d{1,6})""", RegexOption.IGNORE_CASE)
private val hrefRegex = Regex("""(?:^|\s)href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))""", RegexOption.IGNORE_CASE)
// Latin-1 (&nbsp; … &yuml;) in code point order, then the common extras.
private val LATIN1_ENTITIES = ("nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr " +
"deg plusmn sup2 sup3 acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest " +
"Agrave Aacute Acirc Atilde Auml Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml " +
"ETH Ntilde Ograve Oacute Ocirc Otilde Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN szlig " +
"agrave aacute acirc atilde auml aring aelig ccedil egrave eacute ecirc euml igrave iacute icirc iuml " +
"eth ntilde ograve oacute ocirc otilde ouml divide oslash ugrave uacute ucirc uuml yacute thorn yuml").split(' ')
private val ENTITIES: Map<String, String> = LATIN1_ENTITIES.withIndex()
.associate { (i, name) -> name to (0xA0 + i).toChar().toString() } + mapOf(
"amp" to "&", "lt" to "<", "gt" to ">", "quot" to "\"", "apos" to "'",
"ndash" to "–", "mdash" to "—", "hellip" to "…", "bull" to "•",
"lsquo" to "‘", "rsquo" to "’", "ldquo" to "“", "rdquo" to "”", "sbquo" to "‚", "bdquo" to "„",
"euro" to "€", "trade" to "™", "OElig" to "Œ", "oelig" to "œ", "Scaron" to "Š", "scaron" to "š",
"zwnj" to "\u200C", "zwj" to "\u200D", "ensp" to "\u2002", "emsp" to "\u2003", "thinsp" to "\u2009",
)
// A named reference needs its ";": "?id=5&reg=eu" in a link is a query, not "®".
private val entityRegex = Regex("""&(?:(#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6});?|([a-zA-Z][a-zA-Z0-9]{1,31});)""")
/** Decode character references; an unknown name is left as written. */
internal fun decodeEntities(text: String): String {
if ('&' !in text) return text
return entityRegex.replace(text) { match ->
val name = match.groupValues[2]
if (name.isNotEmpty()) return@replace ENTITIES[name] ?: match.value
val body = match.groupValues[1]
val code = when {
body.startsWith("#x", ignoreCase = true) -> body.substring(2).toIntOrNull(16)
else -> body.substring(1).toIntOrNull()
}
if (code == null || code == 0 || !Character.isValidCodePoint(code)) "�" else String(Character.toChars(code))
}
}
@@ -1,6 +1,10 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
@@ -21,27 +25,37 @@ import kotlin.time.Instant
* the extras lives in `MainActivity.insertFormOrNull`.
*/
fun buildInsertEventForm(
beginMillis: Long? = null,
endMillis: Long? = null,
isAllDay: Boolean = false,
beginMillis: Long?,
endMillis: Long?,
isAllDay: Boolean,
title: String?,
description: String?,
location: String? = null,
rrule: String? = null,
location: String?,
rrule: String?,
zone: TimeZone,
now: Instant,
): EventForm {
val (start, end) = if (isAllDay) {
// All-day provider times are UTC midnights with an exclusive end; show
// the last covered day and keep placeholder wall-clock times in case the
// user switches the event to timed (mirrors EventDetail.toEditForm).
val startDate = beginMillis
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
?: now.toLocalDateTime(zone).date
val endExclusive = endMillis?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
allDayFormTimes(startDate, endExclusive)
val endDate = endMillis
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
?: startDate
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
} else {
val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now)
val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt }
?: (startAt + 1.hours)
startAt.toFormTime(zone) to endAt.toFormTime(zone)
val startTime = beginMillis
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
?: nextFullHour(now, zone)
val endTime = endMillis
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
?.takeIf { it >= startTime }
?: (startTime.toInstant(zone) + 1.hours).toLocalDateTime(zone)
startTime to endTime
}
return EventForm(
calendarId = null,
@@ -53,38 +67,12 @@ fun buildInsertEventForm(
description = description.orEmpty(),
// Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:"
// some callers include.
rrule = rrule.normalizedRrule(),
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
)
}
/** The first full hour after [now]: may roll into the next day before midnight. */
internal fun nextFullHour(now: Instant): Instant {
private fun nextFullHour(now: Instant, zone: TimeZone): LocalDateTime {
val hourMillis = 3_600_000L
return Instant.fromEpochMilliseconds((now.toEpochMilliseconds() / hourMillis + 1) * hourMillis)
}
/** Title and description for text shared or selected into the app (#306). */
data class SharedText(val title: String, val description: String)
/**
* Split shared text into an event title and description: the share's subject
* becomes the title and the text the description. Without a subject the text's
* first non-blank line is the title and the rest the description. A title longer
* than [maxTitle] is cut, and the full text then stays in the description.
* Null when there is nothing to use.
*/
fun sharedTextOrNull(subject: String?, text: String?, maxTitle: Int = 255): SharedText? {
val body = text?.trim().orEmpty()
val given = subject?.trim()?.takeIf { it.isNotEmpty() }
val (title, rest) = if (given != null) {
given to body
} else {
val lines = body.lines()
val first = lines.firstOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return null
first to lines.drop(1).joinToString("\n").trim()
}
if (title.length <= maxTitle) return SharedText(title, rest)
val cut = title.take(maxTitle).let { if (it.last().isHighSurrogate()) it.dropLast(1) else it }
val full = if (given != null) listOf(given, body).filter { it.isNotEmpty() }.joinToString("\n\n") else body
return SharedText(cut.trimEnd(), full)
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
return Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
}
@@ -68,35 +68,14 @@ data class EventInstance(
*/
val isRecurring: Boolean = false,
/**
* This device user's own answer to the invitation, as far as the grids care
* (#180, #230).
* This device user answered "no" to the invitation
* (`Events.SELF_ATTENDEE_STATUS`). The event stays on the calendar — it is
* still an appointment someone expects an answer about — but every surface
* strikes it through, and it plans no reminders (#180).
*/
val response: EventResponse = EventResponse.Going,
val isDeclined: Boolean = false,
)
/**
* How this device user stands towards an event's invitation
* (`Events.SELF_ATTENDEE_STATUS`), reduced to the three cases the calendar
* surfaces draw differently (#230).
*/
enum class EventResponse {
/** Your own event, or one you accepted — including a tentative "maybe". */
Going,
/** Invited, no answer given yet: drawn as an outline so it reads as still open. */
Invited,
/**
* You answered "no". The event stays on the calendar — it is still an
* appointment someone expects an answer about — but every surface strikes it
* through, and it plans no reminders (#180).
*/
Declined,
}
/** Shorthand for the declined case, which most surfaces test on its own (#180). */
val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined
/**
* Whether this event has finished relative to [now] — its end is at or before
* the current instant. An in-progress event (already started but not yet ended)
@@ -165,17 +144,6 @@ data class EventDetail(
val isException: Boolean = false,
)
/**
* This event on one occurrence's times. The Events row holds the series start,
* so a recurring event needs the tapped occurrence pinned to render and share right.
*/
fun EventDetail.atOccurrence(beginMillis: Long, endMillis: Long): EventDetail = copy(
instance = instance.copy(
start = Instant.fromEpochMilliseconds(beginMillis),
end = Instant.fromEpochMilliseconds(endMillis),
),
)
/**
* One selectable event colour published by a calendar's account
* (`CalendarContract.Colors`, `TYPE_EVENT`): [key] is the account-scoped
@@ -263,14 +231,6 @@ enum class RecurringWriteScope {
enum class FailureReason {
PermissionRevoked,
NoCalendarsConfigured,
AllCalendarsHidden,
/**
* Calendars exist and are switched on, but none can receive an event: every
* one is read-only, app-managed, or not synced to this device. Distinct from
* [AllCalendarsHidden], which a visibility switch fixes.
*/
NoImportTarget,
ProviderUnavailable,
EventNotFound,
Unknown,
@@ -1,8 +1,5 @@
package de.jeanlucmakiola.calendula.domain
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDate
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDateTime
import de.jeanlucmakiola.calendula.domain.ics.toUtcBasic
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
@@ -60,13 +57,24 @@ fun parseSimpleRecurrence(
rrule: String,
zone: TimeZone = TimeZone.currentSystemDefault(),
): SimpleRecurrence? {
val parts = rruleParts(rrule).associate { part -> (part.key ?: return null) to part.value }
val parts = rrule.removePrefix("RRULE:").split(';')
.filter { it.isNotBlank() }
.associate { token ->
val eq = token.indexOf('=')
if (eq <= 0) return null
token.substring(0, eq).uppercase() to token.substring(eq + 1)
}
if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) {
return null
}
val freq = RecurrenceFreq.entries.firstOrNull { it.name.equals(parts["FREQ"], ignoreCase = true) }
?: return null
val freq = when (parts["FREQ"]?.uppercase()) {
"DAILY" -> RecurrenceFreq.Daily
"WEEKLY" -> RecurrenceFreq.Weekly
"MONTHLY" -> RecurrenceFreq.Monthly
"YEARLY" -> RecurrenceFreq.Yearly
else -> return null
}
val interval = parts["INTERVAL"]?.let { it.toIntOrNull()?.takeIf { n -> n >= 1 } ?: return null } ?: 1
// BYDAY is simple only as plain weekday picks on a weekly rule; ordinal
@@ -116,27 +124,20 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()):
when (val e = end) {
RecurrenceEnd.Never -> Unit
is RecurrenceEnd.Until -> {
append(";UNTIL=")
append(LocalDateTime(e.date, LocalTime(23, 59, 59)).toInstant(zone).toUtcBasic())
val utc = LocalDateTime(e.date, LocalTime(23, 59, 59))
.toInstant(zone)
.toLocalDateTime(TimeZone.UTC)
append(
";UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
utc.year, utc.month.number, utc.day,
utc.hour, utc.minute, utc.second,
),
)
}
is RecurrenceEnd.Count -> append(";COUNT=${e.times}")
}
}
/** One `;`-separated part of an RRULE as written; [key] is null when it has no `KEY=`. */
internal class RrulePart(val raw: String) {
private val eq = raw.indexOf('=')
val key: String? = if (eq > 0) raw.substring(0, eq).uppercase() else null
val value: String = if (eq > 0) raw.substring(eq + 1) else ""
}
/**
* [rule]'s non-blank parts, after an optional leading "RRULE:". What to do
* with a malformed one (reject, repair or skip) is the caller's call.
*/
internal fun rruleParts(rule: String): List<RrulePart> =
rule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }.map(::RrulePart)
internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
DayOfWeek.MONDAY to "MO",
DayOfWeek.TUESDAY to "TU",
@@ -164,7 +165,11 @@ fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
val key = part.substringBefore('=').trim().uppercase()
key == "UNTIL" || key == "COUNT"
}
val untilPart = "UNTIL=" + Instant.fromEpochMilliseconds(untilUtcMillis).toUtcBasic()
val until = Instant.fromEpochMilliseconds(untilUtcMillis).toLocalDateTime(TimeZone.UTC)
val untilPart = "UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
until.year, until.month.number, until.day,
until.hour, until.minute, until.second,
)
return (kept + untilPart).joinToString(";")
}
@@ -173,8 +178,20 @@ fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
* forms are UTC (RFC 5545); the date is taken after converting into [zone] so
* a [toRRule]-rendered value round-trips to the day the user picked.
*/
private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? {
val date = parseBasicDate(raw) ?: return null
if (raw.length < 15 || raw[8] != 'T') return date
return parseBasicDateTime(raw)?.toInstant(TimeZone.UTC)?.toLocalDateTime(zone)?.date
}
private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? = runCatching {
val date = LocalDate(
raw.substring(0, 4).toInt(),
raw.substring(4, 6).toInt(),
raw.substring(6, 8).toInt(),
)
if (raw.length >= 15 && raw[8] == 'T') {
val time = LocalTime(
raw.substring(9, 11).toInt(),
raw.substring(11, 13).toInt(),
raw.substring(13, 15).toInt(),
)
LocalDateTime(date, time).toInstant(TimeZone.UTC).toLocalDateTime(zone).date
} else {
date
}
}.getOrNull()
@@ -19,30 +19,30 @@ import kotlinx.datetime.LocalDate
fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
if (oldStart == newStart) return rrule
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else ""
val parts = rruleParts(rrule)
val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }
if (parts.isEmpty()) return null
var freq = false
var weekly = false
var byDay = false
val rebuilt = parts.map { part ->
val value = part.value.trim()
when (part.key) {
val eq = part.indexOf('=')
if (eq <= 0) return null
val key = part.substring(0, eq).uppercase()
val value = part.substring(eq + 1).trim()
when (key) {
"FREQ" -> {
freq = true
weekly = value.equals("WEEKLY", ignoreCase = true)
part.raw
part
}
"BYDAY" -> {
byDay = true
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
if (!value.equals(old, ignoreCase = true)) return null
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
}
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part.raw
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part
else -> return null
}
}
// BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence.
if (!freq || (byDay && !weekly)) return null
if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null
if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null
return prefix + rebuilt.joinToString(";")
}
@@ -157,36 +157,14 @@ fun timeZoneOptionOf(
* the offset isn't stated twice in ICU's spelling and ours.
*/
fun zoneDescriptor(option: TimeZoneOption): String {
val name = zoneAbbreviation(option)
val statesOffset = option.shortName == "UTC" || option.shortName.startsWith("GMT")
return if (statesOffset) name else "$name · ${formatGmtOffset(option.offsetMinutes)}"
val abbrev = option.shortName
return when {
abbrev == "UTC" -> "UTC"
abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes)
else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}"
}
}
/**
* [option]'s short name on its own — "CEST", "UTC" — with an offset-shaped one
* normalised to our GMT form, as [zoneDescriptor] spells it.
*/
fun zoneAbbreviation(option: TimeZoneOption): String = abbreviation(option.shortName, option.offsetMinutes)
/**
* Zone [id]'s abbreviation [at] an instant, as [zoneAbbreviation] spells it,
* without the localized long name a whole [TimeZoneOption] would look up. Null
* for an id the tz database can't parse.
*/
fun zoneAbbreviationAt(
id: String,
locale: Locale,
at: Instant,
regionOf: (String) -> String? = { null },
): String? {
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
val shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf)
return abbreviation(shortName, zone.rules.getOffset(at).totalSeconds / 60)
}
private fun abbreviation(shortName: String, offsetMinutes: Int): String =
if (shortName.startsWith("GMT")) formatGmtOffset(offsetMinutes) else shortName
/**
* [options] matching [query], best matches first; a blank query returns the list
* unchanged. Matching is accent- and case-insensitive and treats underscores as
@@ -1,47 +0,0 @@
package de.jeanlucmakiola.calendula.domain.ics
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.format
import kotlinx.datetime.format.char
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
/** RFC 5545 basic format, `yyyyMMddTHHmmss`. */
private val BASIC_DATE_TIME = LocalDateTime.Format {
date(LocalDate.Formats.ISO_BASIC)
char('T')
hour()
minute()
second()
}
/** `yyyyMMdd`. */
internal fun LocalDate.toBasic(): String = format(LocalDate.Formats.ISO_BASIC)
/** `yyyyMMddTHHmmss`, floating. */
internal fun LocalDateTime.toBasic(): String = format(BASIC_DATE_TIME)
/** `yyyyMMddTHHmmssZ`, the instant in UTC. */
internal fun Instant.toUtcBasic(): String = toLocalDateTime(TimeZone.UTC).toBasic() + "Z"
/**
* The date in the first eight characters of a basic-format value; whatever
* follows (a time, a `Z`) is ignored. Null when those aren't a valid date.
*/
internal fun parseBasicDate(s: String): LocalDate? = runCatching {
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
}.getOrNull()
/** `yyyyMMddTHHmm[ss]`; the separator isn't checked and missing seconds read as zero. */
internal fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
val date = parseBasicDate(s) ?: return null
val time = LocalTime(
s.substring(9, 11).toInt(),
s.substring(11, 13).toInt(),
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
)
LocalDateTime(date, time)
}.getOrNull()
@@ -2,17 +2,19 @@ package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.zoneOrNull
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.number
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.math.ceil
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
/** Milliseconds in a calendar day; all-day times are UTC midnights, so this is exact. */
private const val DAY_MILLIS = 86_400_000L
private const val MINUTES_PER_DAY = 1_440
/**
@@ -164,19 +166,14 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
}
when {
component != null -> {
// A component whose END line never arrives ends at the next
// one's BEGIN, not at that one's END: reading to the far END
// would fold two events into one body, where the later
// properties overwrite the earlier and one event is lost.
val end = indexOfEnd(lines, i + 1, component)
val body = indexOfNextComponent(lines, i + 1, end)
parseComponent(
body = lines.subList(i + 1, body),
body = lines.subList(i + 1, end),
fileCalendarName = calendarName,
warnings = warnings,
isTask = component == "VTODO",
)?.let(events::add)
i = if (body < end) body else end + 1
i = end + 1
}
line.isBegin("VTIMEZONE") -> {
// Skipped wholesale; TZIDs resolve against the OS tz database.
@@ -316,9 +313,13 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
dtEnd: IcsDateTime?,
duration: String?,
): Instant {
val explicit = dtEnd?.instant ?: duration?.let { start.instant + parseRfc2445DurationMillis(it).milliseconds }
val explicit = dtEnd?.instant ?: duration?.let {
Instant.fromEpochMilliseconds(
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
)
}
if (!start.isAllDay) return explicit ?: start.instant
return explicit?.takeIf { it > start.instant } ?: (start.instant + 1.days)
return explicit?.takeIf { it > start.instant } ?: start.instant.plusDays(1)
}
/**
@@ -337,17 +338,17 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
*/
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
if (lines.isEmpty()) return emptyList()
val startZone = zoneOrNull(start.zoneId) ?: deviceZone
val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone
val startTime = start.instant.toLocalDateTime(startZone).time
return lines
.flatMap { line -> line.value.split(',').map { line to it.trim() } }
.mapNotNull { (line, token) ->
when {
token.isEmpty() -> null
start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.toBasic()
token.contains('T') -> parseExDateTime(token, line, startZone)?.toUtcBasic()
start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode)
token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp)
else -> parseBasicDate(token)
?.let { LocalDateTime(it, startTime).toInstant(startZone).toUtcBasic() }
?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) }
}
}
.distinct()
@@ -367,7 +368,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
val zone = when {
token.endsWith("Z") -> TimeZone.UTC
else -> line.params["TZID"]?.let(::zoneOrNull)
else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() }
?: startZone
}
return ldt.toInstant(zone)
@@ -403,7 +404,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC")
val tzid = line.params["TZID"]
val resolved = tzid?.let { id -> zoneOrNull(id)?.let { id to it } }
val resolved = tzid?.let { id -> runCatching { TimeZone.of(id) }.getOrNull()?.let { id to it } }
if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone)
val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone)
return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId)
@@ -415,6 +416,16 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
fun IcsContentLine.isBegin(component: String) =
name == "BEGIN" && value.trim().equals(component, true)
fun Instant.plusDays(days: Int): Instant =
Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS)
fun dayCode(date: LocalDate): String =
"%04d%02d%02d".format(date.year, date.month.number, date.day)
fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
"%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second)
}
/**
* A single-valued `CATEGORIES`, which is what the Fossify family writes
* for the source calendar's name. A multi-item list is a tag list and
@@ -433,26 +444,42 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
.takeIf { it.isNotEmpty() && !it.equals("null", true) }
}
/**
* Where an unterminated component's body has to stop: the next top-level
* `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A
* nested `VALARM` is part of the body and is not a boundary.
*/
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int =
(from until end).firstOrNull { i ->
val line = parseContentLine(lines[i])
line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))
} ?: end
/** Index of the matching `END:<component>` at/after [from], or list end. */
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int =
(from until lines.size).firstOrNull { i ->
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
var i = from
while (i < lines.size) {
val line = parseContentLine(lines[i])
line != null && line.name == "END" && line.value.trim().equals(component, true)
} ?: lines.size
if (line != null && line.name == "END" &&
line.value.trim().equals(component, true)
) {
return i
}
i++
}
return lines.size
}
fun mapIcsStatus(value: String): EventStatus =
EventStatus.entries.firstOrNull { it.name.equals(value.trim(), ignoreCase = true) }
?: EventStatus.Confirmed
fun mapIcsStatus(value: String): EventStatus = when (value.trim().uppercase()) {
"TENTATIVE" -> EventStatus.Tentative
"CANCELLED" -> EventStatus.Cancelled
else -> EventStatus.Confirmed
}
fun parseBasicDate(s: String): LocalDate? = runCatching {
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
}.getOrNull()
fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
val date = LocalDate(
s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt(),
)
// Format is YYYYMMDD 'T' HHMMSS; seconds optional.
val time = LocalTime(
s.substring(9, 11).toInt(),
s.substring(11, 13).toInt(),
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
)
LocalDateTime(date, time)
}.getOrNull()
}
}
@@ -1,7 +1,5 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.rruleParts
private val VALID_FREQ = setOf(
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
)
@@ -81,12 +79,13 @@ fun sanitizeRrule(raw: String?): SanitizedRrule {
return kept.takeIf { it.isNotEmpty() }?.joinToString(",")
}
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
// being dropped, so it falls through and marks the rule repaired.
for (part in rruleParts(raw.orEmpty().trim())) {
val key = part.key?.trim().orEmpty()
val value = part.value.trim()
for (part in rule.split(';')) {
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
// being dropped, so it falls through and marks the rule repaired.
if (part.isBlank()) continue
val key = part.substringBefore('=', "").trim().uppercase()
val value = if ('=' in part) part.substringAfter('=').trim() else ""
val kept = when {
key.isEmpty() || value.isEmpty() -> null
key == "FREQ" ->
@@ -2,8 +2,9 @@ package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.zoneOrNull
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
@@ -40,7 +41,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
private fun MutableList<String>.appendEvent(event: IcsEvent, dtStamp: Instant) {
add("BEGIN:VEVENT")
add("UID:${event.uid}")
add("DTSTAMP:${dtStamp.toUtcBasic()}")
add("DTSTAMP:${utcStamp(dtStamp)}")
add("SUMMARY:${escapeText(event.summary)}")
appendTimes(event)
event.recurrenceRule?.takeIf { it.isNotBlank() }
@@ -62,24 +63,24 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
private fun MutableList<String>.appendTimes(event: IcsEvent) = when {
event.isAllDay -> {
add("DTSTART;VALUE=DATE:${event.start.toLocalDateTime(TimeZone.UTC).date.toBasic()}")
add("DTEND;VALUE=DATE:${event.end.toLocalDateTime(TimeZone.UTC).date.toBasic()}")
add("DTSTART;VALUE=DATE:${utcDate(event.start)}")
add("DTEND;VALUE=DATE:${utcDate(event.end)}")
}
// Recurring: anchor to wall-clock in the event's own zone.
event.recurrenceRule?.isNotBlank() == true -> {
val zone = zoneOrNull(event.zoneId)
val zone = runCatching { TimeZone.of(event.zoneId) }.getOrNull()
if (zone != null) {
add("DTSTART;TZID=${event.zoneId}:${event.start.toLocalDateTime(zone).toBasic()}")
add("DTEND;TZID=${event.zoneId}:${event.end.toLocalDateTime(zone).toBasic()}")
add("DTSTART;TZID=${event.zoneId}:${localStamp(event.start, zone)}")
add("DTEND;TZID=${event.zoneId}:${localStamp(event.end, zone)}")
} else {
// Unknown zone id → fall back to plain UTC instants.
add("DTSTART:${event.start.toUtcBasic()}")
add("DTEND:${event.end.toUtcBasic()}")
add("DTSTART:${utcStamp(event.start)}")
add("DTEND:${utcStamp(event.end)}")
}
}
else -> {
add("DTSTART:${event.start.toUtcBasic()}")
add("DTEND:${event.end.toUtcBasic()}")
add("DTSTART:${utcStamp(event.start)}")
add("DTEND:${utcStamp(event.end)}")
}
}
@@ -119,5 +120,20 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
// A lead time of 0 fires at start (PT0M); anything positive is "before".
fun triggerValue(minutes: Int): String =
if (minutes <= 0) "PT0M" else "-PT${minutes}M"
fun utcStamp(instant: Instant): String =
basic(instant.toLocalDateTime(TimeZone.UTC)) + "Z"
fun localStamp(instant: Instant, zone: TimeZone): String =
basic(instant.toLocalDateTime(zone))
fun utcDate(instant: Instant): String {
val dt = instant.toLocalDateTime(TimeZone.UTC)
return "%04d%02d%02d".format(dt.year, dt.month.number, dt.day)
}
fun basic(dt: LocalDateTime): String = "%04d%02d%02dT%02d%02d%02d".format(
dt.year, dt.month.number, dt.day, dt.hour, dt.minute, dt.second,
)
}
}
@@ -1,9 +1,9 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.allDayFormTimes
import de.jeanlucmakiola.calendula.domain.normalizedRrule
import de.jeanlucmakiola.calendula.domain.toFormTime
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
@@ -21,9 +21,12 @@ import kotlinx.datetime.toLocalDateTime
*/
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
val (start, end) = if (isAllDay) {
allDayFormTimes(this.start.toLocalDateTime(TimeZone.UTC).date, this.end.toLocalDateTime(TimeZone.UTC).date)
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
} else {
this.start.toFormTime(zone) to this.end.toFormTime(zone)
this.start.toLocalDateTime(zone) to this.end.toLocalDateTime(zone)
}
return EventForm(
calendarId = null,
@@ -35,6 +38,6 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
description = description.orEmpty(),
reminders = semanticReminderMinutes(),
availability = availability,
rrule = recurrenceRule.normalizedRrule(),
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
)
}
File diff suppressed because it is too large Load Diff
@@ -4,15 +4,12 @@ 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.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
/**
@@ -24,39 +21,36 @@ import javax.inject.Inject
@HiltViewModel
class CalendarHostViewModel @Inject constructor(
prefs: SettingsPrefs,
todaySource: TodaySource,
) : ViewModel() {
/** Today, kept current across midnight so the shared focus can follow it (#373). */
val today: StateFlow<LocalDate> = todaySource.today.stateWhileSubscribed(
scope = viewModelScope,
initialValue = currentDate(),
)
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateWhileSubscribed(
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
/** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */
val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig
.map { it.cycle }
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = IMPLEMENTED_VIEWS,
)
/** Order of the views in the navigation drawer (#24); every view always shown. */
val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = IMPLEMENTED_VIEWS,
)
/** Whether each view's jump-to-today control lives in the top bar, not the FAB (#60). */
val todayButtonInToolbar: StateFlow<Boolean> = prefs.todayButtonInToolbar
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
@@ -68,13 +62,9 @@ class CalendarHostViewModel @Inject constructor(
* switched off to be rid of.
*/
val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
private val tapOpensEditorPref = prefs.tapOpensEditor
/** Whether tapping an event opens the edit form rather than the detail screen (#178). */
suspend fun tapOpensEditor(): Boolean = tapOpensEditorPref.first()
}
@@ -1,42 +0,0 @@
package de.jeanlucmakiola.calendula.ui
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
/**
* What a notification, widget, shortcut or another app asked the running app to
* open. MainActivity fills it from each intent (singleTop + onNewIntent route a
* new one into the running activity); CalendarHost acts on a request and clears
* it, so a later recomposition can't act on it twice.
*
* [onExternalFormClosed] runs when a form opened for [insert] closes, so the
* activity can hand control back to the app that asked.
*/
@Stable
class ExternalRequests(val onExternalFormClosed: () -> Unit = {}) {
/** A tapped reminder notification's occurrence (eventId, begin, end), for the detail screen. */
var detailKey by mutableStateOf<LongArray?>(null)
/** A home-screen widget, shortcut or tile navigation (open a date, an event, a view, or create). */
var nav by mutableStateOf<WidgetNavRequest?>(null)
/** An .ics file opened or shared into the app (ACTION_VIEW/SEND). */
var importUri by mutableStateOf<Uri?>(null)
/**
* A prefilled new-event form from an external ACTION_INSERT launch (#30) or
* shared text (#306), opened in the create form for review.
*/
var insert by mutableStateOf<InsertRequest?>(null)
/** An external "edit this event" (ACTION_EDIT), in the detail key's shape. */
var editKey by mutableStateOf<LongArray?>(null)
}
/** A prefilled create form from an external launch, with the source it came from. */
data class InsertRequest(val form: EventForm, val source: ImportSource)
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -18,11 +20,11 @@ import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.ui.common.canReadCalendar
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
@@ -41,12 +43,26 @@ private sealed interface RootTarget {
@Composable
fun RootScreen(
requests: ExternalRequests,
modifier: Modifier = Modifier,
requestedDetailKey: LongArray? = null,
onDetailKeyConsumed: () -> Unit = {},
widgetNavRequest: WidgetNavRequest? = null,
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
onInsertConsumed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(context.canReadCalendar())
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR)
== PackageManager.PERMISSION_GRANTED
)
}
// A launch scan already covers the app coming up with the permission, so
@@ -57,7 +73,9 @@ fun RootScreen(
DisposableEffect(lifecycle) {
val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
hasPermission = context.canReadCalendar()
hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CALENDAR
) == PackageManager.PERMISSION_GRANTED
// Refresh the contact special-dates mirror on foreground (the
// worker is debounced and no-ops when the feature is off). Gated
// on the permission so users without the opt-in never enqueue it.
@@ -127,8 +145,18 @@ fun RootScreen(
when (shown) {
RootTarget.Loading -> Unit
RootTarget.App -> CalendarHost(
requests = requests,
modifier = modifier,
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = onDetailKeyConsumed,
widgetNavRequest = widgetNavRequest,
onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed,
requestedInsertForm = requestedInsertForm,
requestedInsertSource = requestedInsertSource,
onInsertConsumed = onInsertConsumed,
requestedEditKey = requestedEditKey,
onEditKeyConsumed = onEditKeyConsumed,
)
// Both halves of a transition read their position out of the live
// plan; the one the outgoing step was drawn with is already gone.
@@ -3,9 +3,8 @@ package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import java.time.YearMonth
import java.util.Locale
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.yearMonth
/**
* How far ahead the agenda (screen or widget) shows events, starting at today.
@@ -51,7 +50,7 @@ fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (t
DAYS_PER_WEEK - sinceWeekStart
}
AgendaRange.ThisMonth -> {
val daysInMonth = anchor.yearMonth.numberOfDays
val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth()
daysInMonth - anchor.day + 1
}
is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)
@@ -106,8 +105,8 @@ fun agendaRangeWindowSummary(
locale: Locale,
monthAsSpan: Boolean = false,
): String {
val javaStart = start.toJavaLocalDate()
val javaEnd = end.toJavaLocalDate()
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
val dayMonth = localizedDateFormatter(locale, "dMMM")
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
return when {
@@ -1,9 +1,8 @@
package de.jeanlucmakiola.calendula.ui.agenda
import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -11,8 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee
@@ -32,27 +29,23 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import java.util.Locale
// The agenda's row vocabulary, split out of AgendaScreen so the month view's
@@ -133,7 +126,7 @@ internal fun AgendaEventRow(
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val dark = LocalFloretDarkTheme.current
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow(
@@ -183,74 +176,34 @@ internal fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZ
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
is AgendaTimeLabel.Starts -> stringResource(
R.string.agenda_span_starts,
formatTimeOfDay(label.start, zone, is24Hour, locale),
formatTime(label.start, zone, is24Hour, locale),
)
is AgendaTimeLabel.Ends -> stringResource(
R.string.agenda_span_ends,
formatTimeOfDay(label.end, zone, is24Hour, locale),
formatTime(label.end, zone, is24Hour, locale),
)
is AgendaTimeLabel.Range -> "${formatTimeOfDay(label.start, zone, is24Hour, locale)} – " +
formatTimeOfDay(label.end, zone, is24Hour, locale)
is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " +
formatTime(label.end, zone, is24Hour, locale)
}
val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time
}
private fun formatTime(
instant: Instant,
zone: TimeZone,
is24Hour: Boolean,
locale: Locale,
): String {
val t = instant.toLocalDateTime(zone).time
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
}
private fun formatAgendaDate(date: LocalDate): String {
val locale = Locale.getDefault()
val java = date.toJavaLocalDate()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
}
/**
* The agenda's day-by-day list body: a sticky header per day, its events or a
* placeholder row for an event-less day, then a gap. Shared by the Agenda screen
* and the month view's split pane so both lists read the same.
*/
@OptIn(ExperimentalFoundationApi::class)
internal fun LazyListScope.agendaDays(
days: List<AgendaDay>,
today: LocalDate,
zone: TimeZone,
@StringRes emptyText: Int,
onEmptyClick: (LocalDate) -> Unit,
dimmed: (EventInstance, LocalDate) -> Boolean,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
) {
days.forEach { day ->
stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
}
if (day.events.isEmpty()) {
item(key = "placeholder-${day.date}") {
AgendaEmptyDayRow(
text = stringResource(emptyText),
onClick = { onEmptyClick(day.date) },
)
}
} else {
itemsIndexed(
items = day.events,
// Scope the key by day: a multi-day event appears under every
// day it spans, so its instanceId alone is not unique across
// the list (LazyColumn requires unique keys).
key = { _, event -> "${day.date}-${event.instanceId}" },
) { index, event ->
AgendaEventRow(
event = event,
day = day.date,
zone = zone,
position = positionOf(index, day.events.size),
dimmed = dimmed(event, day.date),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
onClick = { onEventClick(event) },
)
}
}
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
}
}
@@ -1,15 +1,6 @@
package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
import de.jeanlucmakiola.calendula.ui.common.ScreenChrome
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import kotlinx.coroutines.flow.drop
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -22,33 +13,34 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableIntState
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -57,14 +49,19 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.isPastOn
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.launch
@@ -79,34 +76,27 @@ import kotlin.time.Instant
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AgendaScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier,
restorePosition: Boolean = false,
viewModel: AgendaViewModel = hiltViewModel(),
) {
// Opens on today, not on the day another view was on (#367). Backing out of
// a day opened from here, or a recreated screen, returns to the day last at
// the top. The top day still goes back to the shared focus.
val focus = LocalViewFocus.current
val opened = rememberSaveable { mutableStateOf(false) }
val restoreTop = remember(viewModel) {
if (restorePosition || opened.value) {
viewModel.lastTopDate
} else {
viewModel.goToToday()
null
}
}
SideEffect { opened.value = true }
val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
var showRangePicker by remember { mutableStateOf(false) }
@@ -116,107 +106,92 @@ fun AgendaScreen(
}
val successState = state as? AgendaUiState.Success
// The agenda window always starts at the anchor, so moving through it is a
// list scroll, not an anchor change — "today" has to bring the list back to
// the top as well, and stay offered while it is scrolled away (#305).
val listState = rememberLazyListState()
// Past days load above the anchor (#367), so the anchor's row counts as the
// start of the list.
val anchorIndex = remember { mutableIntStateOf(0) }
val scrolledAway by remember {
derivedStateOf {
listState.firstVisibleItemIndex != anchorIndex.intValue ||
listState.firstVisibleItemScrollOffset > 0
}
}
val jumpToToday: () -> Unit = {
// Off today, moving the anchor is what repositions the list; on today
// the window doesn't change, so the scroll back is the whole action.
if (isOnToday) scope.launch { listState.animateScrollToItem(anchorIndex.intValue) }
viewModel.goToToday()
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
}
PublishChrome(
ScreenChrome(
title = stringResource(R.string.view_agenda),
titleDate = null,
drawerDate = anchor,
awayFromToday = !isOnToday || (scrolledAway && successState != null),
todayText = stringResource(R.string.agenda_today_action),
),
ScreenActions(
onToday = jumpToToday,
onJumpToDate = { target ->
focus?.date = target
viewModel.goToDate(target)
},
onCreate = { onCreateEvent(anchor, null) },
),
)
ScreenBody(modifier = modifier) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
// One bar at the top: the "showing …" header on the left and the
// session range switcher on the right (one settings toggle).
successState?.takeIf { it.showRangeBar }?.let { s ->
// end lines the selector up with whatever ends the top bar:
// the view switcher's background, or — once #150 hides it —
// the search icon's glyph.
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
AppBarSpacing.Inset
} else {
AppBarSpacing.IconTrailingInset
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(
start = RANGE_BAR_TEXT_INSET,
end = selectorEnd,
top = 8.dp,
bottom = 8.dp,
),
) {
AgendaRangeBanner(
range = s.range,
start = s.anchor,
end = s.rangeEnd,
modifier = Modifier.weight(1f),
)
AgendaRangePill(
range = s.range,
isOverride = s.rangeIsOverride,
onClick = { showRangePicker = true },
)
}
}
AgendaContent(
state = state,
pastDisplay = pastDisplay,
showToday = showToday,
listState = listState,
anchorIndex = anchorIndex,
initialTopDate = restoreTop,
onLoadEarlier = viewModel::loadEarlier,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onTopDay = if (successState?.anchor == anchor) {
{
focus?.date = it
viewModel.rememberTopDay(it)
}
} else null,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
CalendarDrawer(
currentView = selectedView,
currentDate = anchor,
drawerState = drawerState,
viewOrder = drawerViewOrder,
onSelectView = { view ->
onSelectView(view)
scope.launch { drawerState.close() }
},
onJumpToDate = { target ->
viewModel.goToDate(target)
scope.launch { drawerState.close() }
},
onSettings = {
onOpenSettings()
scope.launch { drawerState.close() }
},
)
},
) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
AgendaTopBar(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = viewModel::goToToday,
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = !isOnToday && !todayInToolbar,
todayText = stringResource(R.string.agenda_today_action),
onToday = viewModel::goToToday,
onCreate = { onCreateEvent(anchor, null) },
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
// One bar at the top: the "showing …" header on the left and the
// session range switcher on the right (one settings toggle).
successState?.takeIf { it.showRangeBar }?.let { s ->
Row(
verticalAlignment = Alignment.CenterVertically,
// end aligns the selector's right edge with the top-bar view
// switcher (its 8.dp margin + the app bar's 4.dp inset).
modifier = Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
) {
AgendaRangeBanner(
range = s.range,
start = s.anchor,
end = s.rangeEnd,
modifier = Modifier.weight(1f),
)
AgendaRangePill(
range = s.range,
isOverride = s.rangeIsOverride,
onClick = { showRangePicker = true },
)
}
}
AgendaContent(
state = state,
pastDisplay = pastDisplay,
showToday = showToday,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
)
}
}
}
@@ -270,9 +245,6 @@ private fun AgendaRangePill(
}
}
/** Optical start inset for the range bar's text, set against the title above it. */
private val RANGE_BAR_TEXT_INSET = 28.dp
/**
* A header naming the concrete window currently shown under a "showing …" label,
* e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name
@@ -312,11 +284,6 @@ internal fun AgendaContent(
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
anchorIndex: MutableIntState = remember { mutableIntStateOf(0) },
initialTopDate: LocalDate? = null,
onLoadEarlier: () -> Unit = {},
onTopDay: ((LocalDate) -> Unit)? = null,
) {
when (state) {
AgendaUiState.Loading -> Box(modifier)
@@ -330,42 +297,31 @@ internal fun AgendaContent(
// away (or fade) as they end while the screen stays open.
val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
state.days.mapNotNull { day ->
val remaining = day.events.filterNot { it.isPastOn(day.date, now, state.zone) }
val remaining = day.events.filterNot { it.hasEnded(now) }
if (remaining.isEmpty()) null else day.copy(events = remaining)
}
} else {
state.days
}
// Anchor today with a "nothing left today" placeholder — but only when
// the agenda actually opens on today; a jumped-to date has no today
// in it, so anchoring there would be misleading (#35).
// the window actually starts on today; a jumped-to date has no today in
// it, so anchoring there would be misleading (#35).
val days = anchorTodayIfMissing(
days = filtered,
today = state.today,
enabled = showToday && state.anchor == state.today,
)
// Past days alone don't count as anything coming up.
if (days.none { it.date >= state.anchor }) {
// Nothing to scroll: drop the position a previous list left, so
// the today FAB doesn't linger over an empty screen.
LaunchedEffect(Unit) { listState.scrollToItem(0) }
SideEffect { anchorIndex.intValue = 0 }
if (days.isEmpty()) {
AgendaEmpty(modifier)
} else {
AgendaList(
days = days,
anchor = state.anchor,
anchorIndex = anchorIndex,
initialTopDate = initialTopDate,
onLoadEarlier = onLoadEarlier,
today = state.today,
zone = state.zone,
listState = listState,
dimPast = pastDisplay == PastEventDisplay.DIM,
now = now,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onTopDay = onTopDay,
modifier = modifier,
)
}
@@ -373,94 +329,59 @@ internal fun AgendaContent(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun AgendaList(
days: List<AgendaDay>,
anchor: LocalDate,
anchorIndex: MutableIntState,
initialTopDate: LocalDate?,
onLoadEarlier: () -> Unit,
today: LocalDate,
zone: TimeZone,
listState: LazyListState,
dimPast: Boolean,
now: Instant,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onTopDay: ((LocalDate) -> Unit)?,
modifier: Modifier = Modifier,
) {
val itemDates = agendaItemDates(days)
val anchorRow = itemDates.indexOfFirst { it >= anchor }.coerceAtLeast(0)
SideEffect { anchorIndex.intValue = anchorRow }
// A new anchor opens the list on its row from the first frame, with past
// days above. Keyed on the anchor the days arrived with, not the one just
// requested: scrolling before the new rows compose leaves the list keyed to
// a row that reappears further down, and it follows it there. The first run
// may restore a remembered day instead.
val pendingRestore = remember { arrayOf(initialTopDate) }
DisposableEffect(anchor) {
val target = pendingRestore[0]
pendingRestore[0] = null
val index = target?.let { date -> itemDates.indexOfFirst { it >= date } }
?.takeIf { it >= 0 }
?: anchorRow
listState.requestScrollToItem(index)
onDispose {}
}
// Scrolling up near the first loaded day loads earlier days.
val currentLoadEarlier by rememberUpdatedState(onLoadEarlier)
val loadOnScrollUp = remember(listState) {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (available.y > 0 && listState.firstVisibleItemIndex < LOAD_EARLIER_WITHIN) {
currentLoadEarlier()
}
return Offset.Zero
}
}
}
if (onTopDay != null) {
val currentItemDates by rememberUpdatedState(itemDates)
val currentOnTopDay by rememberUpdatedState(onTopDay)
// The day the list opened on is where the view already is; only a
// scroll moves it on.
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.drop(1)
.collect { index -> currentItemDates.getOrNull(index)?.let(currentOnTopDay) }
}
}
LazyColumn(
modifier = modifier.nestedScroll(loadOnScrollUp),
state = listState,
modifier = modifier,
// Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
) {
agendaDays(
days = days,
today = today,
zone = zone,
// An anchored, event-less today (#35) — "nothing left today".
emptyText = R.string.agenda_no_more_today,
onEmptyClick = onOpenDay,
dimmed = { event, day -> dimPast && event.isPastOn(day, now, zone) },
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
days.forEach { day ->
stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
}
if (day.events.isEmpty()) {
// An anchored, event-less today (#35) — "nothing left today".
item(key = "placeholder-${day.date}") {
AgendaEmptyDayRow(
text = stringResource(R.string.agenda_no_more_today),
onClick = { onOpenDay(day.date) },
)
}
} else {
itemsIndexed(
items = day.events,
// Scope the key by day: a multi-day event appears under every
// day it spans, so its instanceId alone is not unique across
// the list (LazyColumn requires unique keys).
key = { _, event -> "${day.date}-${event.instanceId}" },
) { index, event ->
AgendaEventRow(
event = event,
day = day.date,
zone = zone,
position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now),
modifier = animateItemMotion(),
onClick = { onEventClick(event) },
)
}
}
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
}
}
}
/** How close to the first loaded row an upward scroll starts loading earlier days. */
private const val LOAD_EARLIER_WITHIN = 10
/** The day of each item [AgendaList] lays out for [days], in its order. */
internal fun agendaItemDates(days: List<AgendaDay>): List<LocalDate> =
days.flatMap { day ->
// Header, the placeholder or the events, then the gap.
List(2 + day.events.size.coerceAtLeast(1)) { day.date }
}
@Composable
private fun AgendaEmpty(modifier: Modifier = Modifier) {
Column(
@@ -482,3 +403,51 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AgendaTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
) {
TopAppBar(
title = {
Text(
text = stringResource(R.string.view_agenda),
style = MaterialTheme.typography.titleLarge,
)
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
),
scrollBehavior = scrollBehavior,
)
}
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.domain.spanLastDay
@@ -87,27 +86,32 @@ fun groupAgendaDays(
return byDay.map { (date, dayEvents) ->
AgendaDay(
date = date,
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
events = dayEvents.sortedWith(
compareByDescending<EventInstance> { it.isAllDay }
.thenBy { it.start }
.thenBy { it.title },
),
)
}
}
/**
* Ensure [today] surfaces in the agenda even when it carries no (remaining)
* events, by inserting an empty-event [AgendaDay] in date order that the agenda
* Ensure [today] surfaces as the first agenda day even when it carries no
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and
* when today already has its own day in [days]. Keeps today anchored so a glance
* tells today's events apart from a future day's (issue #35).
* when today already has its own day in [days]. Keeps today anchored at the top
* so a glance tells today's events apart from a future day's (issue #35).
*/
fun anchorTodayIfMissing(
days: List<AgendaDay>,
today: LocalDate,
enabled: Boolean,
): List<AgendaDay> {
if (!enabled || days.any { it.date == today }) return days
val at = days.indexOfFirst { it.date > today }.let { if (it < 0) days.size else it }
return days.take(at) + AgendaDay(today, emptyList()) + days.drop(at)
}
): List<AgendaDay> =
if (enabled && days.none { it.date == today }) {
listOf(AgendaDay(today, emptyList())) + days
} else {
days
}
/**
* State for the Agenda view: a flat, forward-looking list of upcoming events
@@ -117,10 +121,8 @@ sealed interface AgendaUiState {
data object Loading : AgendaUiState
data class Failure(val reason: FailureReason) : AgendaUiState
data class Success(
/** The day the agenda opens on (today, or a jumped-to date). */
/** First day of the loaded window (today, or a jumped-to date). */
val anchor: LocalDate,
/** First loaded day, before [anchor] once past days are loaded (#367). */
val windowStart: LocalDate,
val today: LocalDate,
val days: List<AgendaDay>,
/** The range currently in effect — the saved default or a session override. */
@@ -1,22 +1,16 @@
package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.ui.common.dayRange
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.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -26,14 +20,17 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
@@ -42,7 +39,6 @@ import javax.inject.Inject
class AgendaViewModel @Inject constructor(
private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
@@ -65,8 +61,9 @@ class AgendaViewModel @Inject constructor(
* the screen combines it with a per-minute "now" to fade or drop past rows.
*/
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = PastEventDisplay.SHOW,
)
@@ -77,35 +74,19 @@ class AgendaViewModel @Inject constructor(
* alongside the data state like [pastEventDisplay] rather than re-querying.
*/
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = true,
)
private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate
get() = currentDate()
get() = Clock.System.now().toLocalDateTime(zone).date
// The anchor and how many days before it are loaded, moved together so a
// jump never queries a window that pairs the new anchor with the old span.
private val _window = MutableStateFlow(AgendaWindow(todayDate, PAST_CHUNK_DAYS))
val anchor: StateFlow<LocalDate> = _window
.map { it.anchor }
.stateIn(viewModelScope, SharingStarted.Eagerly, todayDate)
/** The day last scrolled to the top, so a return to the agenda lands back on it. */
var lastTopDate: LocalDate? = null
private set
/**
* Today, kept current across midnight (#373). An agenda still starting on
* yesterday moves on to the new day; one jumped elsewhere stays put.
*/
private val today: StateFlow<LocalDate> =
todaySource.today.stateFollowingDays(viewModelScope, todayDate) { old, date ->
if (_window.value.anchor == old) _window.value = _window.value.copy(anchor = date)
}
private val _anchor = MutableStateFlow(todayDate)
val anchor: StateFlow<LocalDate> = _anchor
// A transient, in-view override of the saved agenda range. Held in memory
// (not persisted), so it survives view switches and rotation within the
@@ -113,17 +94,9 @@ class AgendaViewModel @Inject constructor(
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
val state: StateFlow<AgendaUiState> =
combine(
_window,
agendaSettings,
_rangeOverride,
weekStart,
settingsPrefs.pastEventDisplay,
) { window, settings, override, weekStart, pastDisplay ->
combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart ->
AgendaParams(
anchor = window.anchor,
// Hidden past events leave nothing to scroll back to (#367).
pastDays = if (pastDisplay == PastEventDisplay.HIDE) 0 else window.pastDays,
anchor = anchor,
range = override ?: settings.range,
rangeIsOverride = override != null && override != settings.range,
weekStart = weekStart,
@@ -132,50 +105,32 @@ class AgendaViewModel @Inject constructor(
}
.flatMapLatest { params ->
val window = agendaRange(
params.windowStart,
params.pastDays + params.range.dayCount(params.anchor, params.weekStart) - 1,
params.anchor,
params.range.dayCount(params.anchor, params.weekStart) - 1,
zone,
)
combine(
repository.calendars(),
repository.instances(window),
today,
) { calendars, instances, today ->
buildState(params, calendars, instances, today)
) { calendars, instances ->
buildState(params, calendars, instances)
}
}
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = AgendaUiState.Loading,
)
fun goToToday() {
goToDate(todayDate)
_anchor.value = todayDate
}
/** Jump the agenda window to start on a specific date (drawer jump-to-date). */
fun goToDate(date: LocalDate) {
if (_window.value.anchor == date) return
_window.value = AgendaWindow(date, PAST_CHUNK_DAYS)
lastTopDate = null
}
/**
* Extend the window [PAST_CHUNK_DAYS] further back (#367). Ignored while the
* previous extension is still loading, so a scroll that keeps asking only
* moves one chunk at a time.
*/
fun loadEarlier() {
val loaded = (state.value as? AgendaUiState.Success)?.windowStart ?: return
val window = _window.value
if (loaded != window.start) return
_window.value = window.copy(pastDays = window.pastDays + PAST_CHUNK_DAYS)
}
fun rememberTopDay(date: LocalDate) {
lastTopDate = date
_anchor.value = date
}
/** Temporarily override the agenda range for this session (the bottom-left pill). */
@@ -188,38 +143,31 @@ class AgendaViewModel @Inject constructor(
val showBar: Boolean,
)
private data class AgendaWindow(val anchor: LocalDate, val pastDays: Int) {
val start: LocalDate get() = anchor.minus(pastDays, DateTimeUnit.DAY)
}
private data class AgendaParams(
val anchor: LocalDate,
val pastDays: Int,
val range: AgendaRange,
val rangeIsOverride: Boolean,
val weekStart: DayOfWeek,
val showRangeBar: Boolean,
) {
val windowStart: LocalDate get() = anchor.minus(pastDays, DateTimeUnit.DAY)
}
)
private fun buildState(
params: AgendaParams,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
today: LocalDate,
): AgendaUiState {
calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) }
if (calendars.isEmpty()) {
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val anchor = params.anchor
val rangeEnd = anchor.plus(
params.range.dayCount(anchor, params.weekStart) - 1,
DateTimeUnit.DAY,
)
val days = groupAgendaDays(params.windowStart, rangeEnd, instances, zone)
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
return AgendaUiState.Success(
anchor = anchor,
windowStart = params.windowStart,
today = today,
today = todayDate,
days = days,
range = params.range,
rangeIsOverride = params.rangeIsOverride,
@@ -230,9 +178,9 @@ class AgendaViewModel @Inject constructor(
}
}
/** How many days each scroll back into the past loads (#367). */
internal const val PAST_CHUNK_DAYS = 30
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> =
dayRange(anchor, anchor.plus(days, DateTimeUnit.DAY), zone)
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> {
val from = anchor.atStartOfDayIn(zone)
val to = anchor.plus(days, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
return from..to
}
@@ -6,8 +6,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
import de.jeanlucmakiola.calendula.ui.common.sampleAgendaEvents
import de.jeanlucmakiola.floret.components.ScaledViewPreview
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
@@ -51,7 +51,6 @@ private fun sampleAgendaState(today: LocalDate, zone: TimeZone): AgendaUiState.S
val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY)
return AgendaUiState.Success(
anchor = today,
windowStart = today,
today = today,
days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone),
range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS),
@@ -1,13 +1,12 @@
package de.jeanlucmakiola.calendula.ui.calendars
import android.net.Uri
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -19,7 +18,6 @@ import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@@ -49,21 +47,16 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.isEventTarget
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.relativeTimeAgo
import java.time.LocalDate
// SAF mime filter for the restore picker. Source apps hand `.ics` files out
@@ -85,16 +78,20 @@ private val RESTORE_MIME_TYPES = arrayOf(
fun BackupScreen(
onBack: () -> Unit,
onImport: (Uri) -> Unit,
visible: Boolean = true,
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val state by viewModel.backupState.collectAsStateWithLifecycle()
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
// Export covers local calendars only; managed special-dates mirrors are
// rebuilt from contacts. Restore can target anything the import picker offers.
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
val canImport = calendars.any { it.isEventTarget }
// Exports everything eligible (null); the per-calendar selector owns its
// own launcher.
val createBackup = rememberLauncherForActivityResult(
@@ -135,43 +132,78 @@ fun BackupScreen(
CollapsingScaffold(
title = stringResource(R.string.settings_section_backup),
onBack = onBack,
// Loading and failure fill the screen and centre themselves, which they
// can only do in an unscrolled column — the scrolling one measures them
// against an unbounded height and leaves them hanging under the header.
scrollable = state is BackupUiState.Ready,
snackbarHost = { SnackbarHost(snackbarHostState) },
predictiveBack = true,
visible = visible,
) {
when (val s = state) {
BackupUiState.Loading -> BackupLoading()
is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack)
is BackupUiState.Ready -> BackupContent(
exportable = s.exportable,
canImport = s.canImport,
autoBackup = autoBackup,
viewModel = viewModel,
onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
onPickFolder = { runCatching { pickFolder.launch(null) } },
onExportAll = {
runCatching { createBackup.launch(defaultBackupName()) }
HintText(stringResource(R.string.calendars_backup_hint))
if (exportable.isNotEmpty()) {
GroupedRow(
title = stringResource(R.string.calendars_backup_action),
position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
onClick = {
// A single exportable calendar skips the selector.
if (exportable.size == 1) {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
} else {
showExportPicker = true
}
},
onExportPick = { showExportPicker = true },
onEditInterval = { showInterval = true },
)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
summary = stringResource(R.string.calendars_restore_hint),
position = Position.Middle,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup),
summary = stringResource(R.string.calendars_auto_backup_hint),
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
leading = { LeadingAvatar(Icons.Default.Schedule) },
trailing = {
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
},
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
)
if (autoBackup.enabled) {
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_folder),
summary = rememberFolderName(autoBackup.folderUri)
?: stringResource(R.string.calendars_auto_backup_folder_unset),
position = Position.Middle,
onClick = { runCatching { pickFolder.launch(null) } },
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_interval),
summary = backupIntervalLabel(autoBackup.intervalMinutes),
position = Position.Bottom,
onClick = { showInterval = true },
)
HintText(backupStatusText(autoBackup.status))
}
} else if (canImport) {
// Nothing to back up, but restore is still possible — don't hide
// it behind export eligibility.
SectionHeader(stringResource(R.string.calendars_restore_header))
HintText(stringResource(R.string.calendars_restore_hint))
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
)
}
}
// Gated on Ready rather than resetting the flag when the state leaves it:
// flipping it here would be a write during composition.
(state as? BackupUiState.Ready)?.let { ready ->
if (showExportPicker) {
ExportCalendarPicker(
calendars = ready.exportable,
onExport = viewModel::exportBackup,
onDismiss = { showExportPicker = false },
)
}
if (showExportPicker) {
ExportCalendarPicker(
calendars = exportable,
onExport = viewModel::exportBackup,
onDismiss = { showExportPicker = false },
)
}
if (showInterval) {
BackupIntervalDialog(
@@ -182,103 +214,6 @@ fun BackupScreen(
}
}
/** The screen's loading state, centred in the scaffold's content column. */
@Composable
private fun BackupLoading() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
/** Default file name for a one-shot export. */
private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.ics"
/**
* The working screen: export (when there are local calendars to export), restore,
* and the automatic-backup block. Restore is always on screen — when nothing can
* receive the events it says so and routes to the calendar manager rather than
* disappearing, which is what #304 reported as an invisible button.
*/
@Composable
private fun BackupContent(
exportable: List<CalendarSource>,
canImport: Boolean,
autoBackup: AutoBackupUiState,
viewModel: CalendarsViewModel,
onRestore: () -> Unit,
onPickFolder: () -> Unit,
onExportAll: () -> Unit,
onExportPick: () -> Unit,
onEditInterval: () -> Unit,
) {
val manageCalendars = LocalManageCalendars.current
// Restore never depends on export eligibility, and never disappears: when no
// calendar can receive events it explains that and offers the way to fix it.
val restoreSummary = if (canImport) {
stringResource(R.string.calendars_restore_hint)
} else {
stringResource(R.string.calendars_restore_unavailable)
}
val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore)
if (exportable.isEmpty()) {
GroupedSectionHeader(stringResource(R.string.calendars_restore_header))
GroupedHint(restoreSummary, style = MaterialTheme.typography.bodySmall)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = onRestoreClick,
)
return
}
GroupedHint(stringResource(R.string.calendars_backup_hint), style = MaterialTheme.typography.bodySmall)
GroupedRow(
title = stringResource(R.string.calendars_backup_action),
position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
// A single exportable calendar skips the selector.
onClick = if (exportable.size == 1) onExportAll else onExportPick,
)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
summary = restoreSummary,
position = Position.Middle,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = onRestoreClick,
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup),
summary = stringResource(R.string.calendars_auto_backup_hint),
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
leading = { LeadingAvatar(Icons.Default.Schedule) },
trailing = {
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
},
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
)
if (autoBackup.enabled) {
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_folder),
summary = rememberFolderName(autoBackup.folderUri)
?: stringResource(R.string.calendars_auto_backup_folder_unset),
position = Position.Middle,
onClick = onPickFolder,
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_interval),
summary = backupIntervalLabel(autoBackup.intervalMinutes),
position = Position.Bottom,
onClick = onEditInterval,
)
GroupedHint(backupStatusText(autoBackup.status), style = MaterialTheme.typography.bodySmall)
}
}
/**
* Choose which local calendars to include in a one-time `.ics` export. Defaults
* to all selected; the Export action opens the SAF save dialog and hands back
@@ -312,13 +247,12 @@ private fun ExportCalendarPicker(
FullScreenPicker(
title = stringResource(R.string.calendars_export_title),
onDismiss = onDismiss,
predictiveBack = true,
) {
GroupedHint(stringResource(R.string.calendars_export_hint), style = MaterialTheme.typography.bodySmall)
HintText(stringResource(R.string.calendars_export_hint))
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id in selected
GroupedRow(
title = calendar.displayName.orUnnamed(),
title = calendar.displayName,
summary = calendar.description,
position = positionOf(index, calendars.size),
leading = { CalendarColorChip(calendar.color) },
@@ -380,7 +314,9 @@ private fun backupIntervalLabel(minutes: Long): String {
@Composable
private fun backupStatusText(status: BackupStatus): String {
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
val relative = relativeTimeAgo(status.lastRun)
val relative = DateUtils.getRelativeTimeSpanString(
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
).toString()
return if (status.lastSuccess) {
stringResource(R.string.calendars_auto_backup_status_ok, relative)
} else {
@@ -1,53 +0,0 @@
package de.jeanlucmakiola.calendula.ui.calendars
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.domain.isEventTarget
/**
* State of the Backup & restore screen (#69). Three states, because the calendar
* list it is derived from has all three: it arrives empty, it can throw, and
* "loaded but nothing to offer" is a real outcome the screen used to render as a
* blank page (#304).
*/
sealed interface BackupUiState {
data object Loading : BackupUiState
data class Failure(val reason: FailureReason) : BackupUiState
/**
* At least one half of the screen works. [exportable] may be empty (restore
* only) and [canImport] may be false (export only), but never both — that is
* a [Failure].
*/
data class Ready(
val exportable: List<CalendarSource>,
val canImport: Boolean,
) : BackupUiState
}
/**
* What the screen can offer for this calendar list.
*
* Export covers the app's own local calendars; managed special-dates mirrors are
* rebuilt from contacts, so they are excluded. Restore can target anything the
* import picker offers ([isEventTarget]).
*
* A failure is raised only when *neither* is possible — deliberately not
* [calendarListFailure] on its own, which would call an all-hidden device a
* failure while its local calendars are still perfectly exportable. When both
* halves are dead the list itself usually says why (no calendars, everything
* switched off); [FailureReason.NoImportTarget] covers the remaining case, where
* calendars exist and are visible but every one of them is read-only, managed or
* not synced to the device.
*/
fun backupUiState(calendars: List<CalendarSource>): BackupUiState {
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
val canImport = calendars.any { it.isEventTarget }
if (exportable.isEmpty() && !canImport) {
return BackupUiState.Failure(
calendarListFailure(calendars) ?: FailureReason.NoImportTarget,
)
}
return BackupUiState.Ready(exportable = exportable, canImport = canImport)
}
@@ -6,27 +6,34 @@ import android.content.Intent
import android.provider.Settings
import androidx.compose.animation.AnimatedVisibility
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.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
@@ -34,14 +41,20 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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
@@ -72,42 +85,23 @@ import de.jeanlucmakiola.calendula.domain.stateLabels
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.AccountKey
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarVisibilitySwitch
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.heightIn
import androidx.annotation.StringRes
import androidx.compose.foundation.border
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.filled.Check
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.ImeAction
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedSurface
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
@@ -124,7 +118,6 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
fun CalendarsScreen(
onBack: () -> Unit,
onOpenBackup: () -> Unit,
visible: Boolean = true,
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
@@ -169,7 +162,6 @@ fun CalendarsScreen(
onConsumeError = viewModel::consumeError,
onOpenBackup = onOpenBackup,
onBack = onBack,
visible = visible,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id },
onSetVisible = viewModel::setCalendarVisible,
@@ -186,7 +178,6 @@ private fun CalendarsList(
onConsumeError: () -> Unit,
onOpenBackup: () -> Unit,
onBack: () -> Unit,
visible: Boolean,
onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit,
onSetVisible: (Long, Boolean) -> Unit,
@@ -212,10 +203,9 @@ private fun CalendarsList(
onBack = onBack,
snackbarHost = { SnackbarHost(snackbarHostState) },
predictiveBack = true,
visible = visible,
) {
// What the per-calendar / per-account switches below actually do.
GroupedHint(stringResource(R.string.calendars_visibility_hint), style = MaterialTheme.typography.bodySmall)
HintText(stringResource(R.string.calendars_visibility_hint))
// Local (device-only) calendars — one collapsible group. The header's
// "+" adds a calendar; the switch enables/disables them all at once;
@@ -236,13 +226,24 @@ private fun CalendarsList(
onToggleAll = { enabled -> onSetAccountVisible(local.map { it.id }, enabled) },
) {
if (local.isEmpty()) {
GroupedHint(stringResource(R.string.calendars_local_empty), style = MaterialTheme.typography.bodySmall)
HintText(stringResource(R.string.calendars_local_empty))
} else {
local.forEachIndexed { index, calendar ->
CalendarManagerRow(
calendar = calendar,
last = index == local.lastIndex,
onSetVisible = onSetVisible,
val disabled = !calendar.isVisibleInSystem
GroupedRow(
title = calendar.displayName,
summary = calendarRowSummary(calendar),
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = {
EnableSwitch(
calendarName = calendar.displayName,
enabled = !disabled,
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
)
},
onClick = { onEdit(calendar) },
)
}
@@ -271,8 +272,8 @@ private fun CalendarsList(
// Synced calendars — read-only, grouped by account. Each account is a
// collapsible group whose header opens the source app (icon) and toggles
// all of its calendars at once (switch).
GroupedSectionHeader(stringResource(R.string.calendars_synced_header))
GroupedHint(stringResource(R.string.calendars_synced_hint), style = MaterialTheme.typography.bodySmall)
SectionHeader(stringResource(R.string.calendars_synced_header))
HintText(stringResource(R.string.calendars_synced_hint))
synced
.groupByAccount()
.forEach { group ->
@@ -313,10 +314,27 @@ private fun CalendarsList(
// bottom, dimmed and switchless.
val ordered = cals.orderedForManager()
ordered.forEachIndexed { index, calendar ->
CalendarManagerRow(
calendar = calendar,
last = index == ordered.lastIndex,
onSetVisible = onSetVisible,
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
GroupedRow(
title = calendar.displayName,
summary = calendarRowSummary(calendar),
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = if (calendar.hasVisibilitySwitch) {
{
EnableSwitch(
calendarName = calendar.displayName,
enabled = calendar.isVisibleInSystem,
onToggle = { enabled ->
onSetVisible(calendar.id, enabled)
},
)
}
} else {
null
},
)
}
}
@@ -325,15 +343,9 @@ private fun CalendarsList(
}
private const val SWATCHES_PER_ROW = 6
/**
* Create or edit a local calendar: a name field over the palette of calendar
* colours, on the family's full-screen sheet with the commit in its title bar.
* The same sheet Agendula edits a task list in, so the two read as one product.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun CalendarEditor(
private fun CalendarEditor(
sessionKey: Int,
isNew: Boolean,
initialName: String,
@@ -348,50 +360,121 @@ internal fun CalendarEditor(
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
var confirmDelete by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
val valid = name.isNotBlank()
val commit = { if (valid) onSave(name.trim(), color, description.trim().ifEmpty { null }) }
FullScreenPicker(
title = stringResource(
if (isNew) R.string.calendars_new_title else R.string.calendars_edit_title,
),
onDismiss = onClose,
predictiveBack = true,
actions = {
Button(
onClick = commit,
enabled = valid,
modifier = Modifier.padding(end = 12.dp),
) { Text(stringResource(R.string.event_edit_save)) }
Scaffold(
modifier = Modifier
.predictiveBack(onBack = onClose)
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
topBar = {
TopAppBar(
title = {
Text(
stringResource(
if (isNew) R.string.calendars_new_title
else R.string.calendars_edit_title,
),
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.event_edit_close),
)
}
},
actions = {
if (!isNew) {
// Disabled rather than hidden while the special-dates
// sync owns this calendar; the card below says why.
IconButton(
onClick = { confirmDelete = true },
enabled = !deleteLocked,
) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete),
tint = if (deleteLocked) {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
} else {
MaterialTheme.colorScheme.error
},
)
}
}
// Filled save button, matching the event editor's top bar.
Button(
onClick = {
onSave(name.trim(), color, description.trim().ifEmpty { null })
},
enabled = name.isNotBlank(),
modifier = Modifier.padding(end = 12.dp),
) {
Text(stringResource(R.string.event_edit_save))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) {
if (deleteLocked) {
ManagedNote()
Spacer(Modifier.height(20.dp))
}
CalendarNameField(
name = name,
color = color,
focusRequester = focusRequester,
onNameChange = { name = it },
onImeAction = commit,
)
// Inside the picker: it is a Dialog, so a request made from the caller's
// composition can run before this field's node exists.
// A new calendar opens with the keyboard up: naming it is the whole task.
LaunchedEffect(isNew) { if (isNew) focusRequester.requestFocus() }
Spacer(Modifier.height(20.dp))
EditorSectionLabel(stringResource(R.string.calendars_color_label))
CalendarColorGrid(selected = color, onSelect = { color = it })
Spacer(Modifier.height(20.dp))
EditorSectionLabel(stringResource(R.string.calendars_description_label))
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
Box(Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(16.dp)) {
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (deleteLocked) {
EditorCard(
icon = Icons.Default.Info,
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
iconAtTop = true,
) {
Text(
text = stringResource(R.string.calendars_managed_delete_locked),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
InlineTextField(
value = name,
onValueChange = { name = it },
placeholder = stringResource(R.string.calendars_name_label),
textStyle = MaterialTheme.typography.titleLarge,
capitalization = KeyboardCapitalization.Sentences,
)
}
EditorCard(
icon = Icons.Default.Palette,
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
iconAtTop = true,
) {
Text(
text = stringResource(R.string.calendars_color_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
ColorSwatchRow(
colors = CalendarColorPalette.all,
selected = color,
onSelect = { color = it },
dark = dark,
)
}
EditorCard(
icon = Icons.AutoMirrored.Filled.Notes,
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
iconAtTop = true,
) {
InlineTextField(
value = description,
onValueChange = { description = it },
@@ -402,21 +485,20 @@ internal fun CalendarEditor(
)
}
}
if (!isNew && !deleteLocked) {
Spacer(Modifier.height(24.dp))
DeleteCalendarRow(onClick = { confirmDelete = true })
}
Spacer(Modifier.height(24.dp))
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text(stringResource(R.string.calendars_delete_confirm_title)) },
text = { Text(stringResource(R.string.calendars_delete_confirm_message, initialName)) },
text = {
Text(stringResource(R.string.calendars_delete_confirm_message, initialName))
},
confirmButton = {
TextButton(onClick = { confirmDelete = false; onDelete() }) {
TextButton(onClick = {
confirmDelete = false
onDelete()
}) {
Text(
stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
@@ -432,205 +514,6 @@ internal fun CalendarEditor(
}
}
/** The name, with the chosen colour beside it so the two read as one thing. */
@Composable
private fun CalendarNameField(
name: String,
color: Int,
focusRequester: FocusRequester,
onNameChange: (String) -> Unit,
onImeAction: () -> Unit,
) {
val dark = LocalFloretDarkTheme.current
val soften = LocalSoftenColors.current
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.CalendarMonth,
contentDescription = null,
tint = eventAccent(color, dark, soften),
modifier = Modifier.size(22.dp),
)
}
InlineTextField(
value = name,
onValueChange = onNameChange,
placeholder = stringResource(R.string.calendars_name_label),
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Done,
onImeAction = onImeAction,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
)
}
}
}
/**
* The palette as two rows of round swatches; the chosen one carries a check.
*
* Drawn through the app's own tone pass rather than raw, so a swatch is the
* colour the calendar's events will actually appear in — the user's "soften
* colours" preference included.
*/
@Composable
private fun CalendarColorGrid(selected: Int, onSelect: (Int) -> Unit) {
val dark = LocalFloretDarkTheme.current
val soften = LocalSoftenColors.current
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
Column(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 16.dp)
.selectableGroup(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
CalendarColorPalette.all.chunked(SWATCHES_PER_ROW).forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
row.forEach { swatch ->
ColorSwatch(
fill = eventFill(swatch, dark, soften),
label = stringResource(swatchLabel(swatch)),
selected = swatch == selected,
onClick = { onSelect(swatch) },
modifier = Modifier.weight(1f),
)
}
// Keeps a short final row's swatches the size a full row's
// are, rather than stretching them across the width.
repeat(SWATCHES_PER_ROW - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
}
@Composable
private fun ColorSwatch(
fill: Color,
label: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.aspectRatio(1f)
.clip(CircleShape)
.background(fill)
.then(
if (selected) {
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
} else {
Modifier
},
)
// selectable, not clickable: the swatch carries its chosen state in
// semantics too, so the check is not the only cue.
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
.semantics { contentDescription = label },
contentAlignment = Alignment.Center,
) {
if (selected) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = eventInk(fill, alpha = 0.7f),
modifier = Modifier.size(22.dp),
)
}
}
}
/** Names the swatch for a screen reader — twelve circles are otherwise one label. */
@StringRes
private fun swatchLabel(argb: Int): Int = when (argb) {
CalendarColorPalette.Mauve -> R.string.color_name_mauve
CalendarColorPalette.Red -> R.string.color_name_red
CalendarColorPalette.Orange -> R.string.color_name_orange
CalendarColorPalette.Amber -> R.string.color_name_amber
CalendarColorPalette.Olive -> R.string.color_name_olive
CalendarColorPalette.Green -> R.string.color_name_green
CalendarColorPalette.Teal -> R.string.color_name_teal
CalendarColorPalette.Cyan -> R.string.color_name_cyan
CalendarColorPalette.Blue -> R.string.color_name_blue
CalendarColorPalette.Indigo -> R.string.color_name_indigo
CalendarColorPalette.Purple -> R.string.color_name_purple
else -> R.string.color_name_pink
}
/** Said in the sheet rather than on a disabled button: the sync owns this one. */
@Composable
private fun ManagedNote() {
GroupedSurface(
position = Position.Alone,
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(Icons.Default.Info, contentDescription = null)
Text(
text = stringResource(R.string.calendars_managed_delete_locked),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun DeleteCalendarRow(onClick: () -> Unit) {
GroupedSurface(
position = Position.Alone,
modifier = Modifier.padding(horizontal = 16.dp),
onClick = onClick,
color = MaterialTheme.colorScheme.errorContainer,
) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
text = stringResource(R.string.event_detail_delete),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
}
@Composable
private fun EditorSectionLabel(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, bottom = 8.dp),
)
}
/**
* The row's supporting line: the states that make this calendar behave unlike a
* plain writable one (#76), then its own description.
@@ -655,37 +538,17 @@ private fun calendarRowSummary(calendar: CalendarSource): String? {
* `Calendars.VISIBLE`. Unchecked drops the calendar out of every surface and
* stops its reminders. Carries its own content description.
*/
/**
* One calendar in a group: dimmed while switched off or not syncing, with the
* visibility switch only where it can change anything.
*/
@Composable
private fun CalendarManagerRow(
calendar: CalendarSource,
last: Boolean,
onSetVisible: (Long, Boolean) -> Unit,
onClick: (() -> Unit)? = null,
private fun EnableSwitch(
calendarName: String,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
GroupedRow(
title = calendar.displayName.orUnnamed(),
summary = calendarRowSummary(calendar),
position = if (last) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = if (calendar.hasVisibilitySwitch) {
{
CalendarVisibilitySwitch(
calendarName = calendar.displayName.orUnnamed(),
enabled = calendar.isVisibleInSystem,
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
)
}
} else {
null
},
onClick = onClick,
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
Switch(
checked = enabled,
onCheckedChange = onToggle,
modifier = Modifier.semantics { contentDescription = label },
)
}
@@ -693,6 +556,36 @@ private fun CalendarManagerRow(
private fun dimIf(disabled: Boolean): Modifier =
if (disabled) Modifier.alpha(0.38f) else Modifier
/** Tonal field card matching the event editor's design (icon + content). */
@Composable
private fun EditorCard(
icon: ImageVector,
iconTint: Color,
iconAtTop: Boolean = false,
content: @Composable () -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = iconTint,
modifier = Modifier
.padding(top = if (iconAtTop) 2.dp else 0.dp)
.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) { content() }
}
}
}
/**
* One collapsible calendar group rendered as a connected card. The header row is
@@ -824,6 +717,33 @@ private fun CalendarGroupMenu(
}
@Composable
internal fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
// The cards' own edge, so header and group share a left margin.
modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 16.dp,
bottom = 4.dp,
),
)
}
@Composable
internal fun HintText(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
)
}
/**
* Pick the app to open for managing a synced calendar's account. The account's
* own authenticator package (resolved from [AccountManager], no permission
@@ -9,23 +9,22 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
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.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
@@ -51,26 +50,12 @@ class CalendarsViewModel @Inject constructor(
repository.calendars()
.catch { emit(emptyList()) }
.flowOn(io)
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyList(),
)
/**
* The Backup & restore screen's own view of that list, with the loading and
* failure states [calendars] flattens away — it starts empty and catches to
* empty, which that screen used to render as a blank page (#304).
*/
val backupState: StateFlow<BackupUiState> =
repository.calendars()
.map { backupUiState(it) }
.catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateWhileSubscribed(
scope = viewModelScope,
initialValue = BackupUiState.Loading,
)
/** Automatic-backup settings + last-run status, for the Backup section UI. */
val autoBackup: StateFlow<AutoBackupUiState> = combine(
settingsPrefs.autoBackupEnabled,
@@ -81,8 +66,9 @@ class CalendarsViewModel @Inject constructor(
AutoBackupUiState(enabled, interval, folder, status)
}
.flowOn(io)
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = AutoBackupUiState(),
)
@@ -99,8 +85,9 @@ class CalendarsViewModel @Inject constructor(
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
}
.flowOn(io)
.stateWhileSubscribed(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(),
)
@@ -56,14 +56,12 @@ fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
* account comes from when another account shares the name (#77).
*/
@Composable
fun accountGroupTitle(group: CalendarAccountGroup): String {
val label = group.label.orUnnamed()
return if (!group.ambiguous) {
label
fun accountGroupTitle(group: CalendarAccountGroup): String =
if (!group.ambiguous) {
group.label
} else {
stringResource(R.string.calendars_account_from_source, label, sourceAppName(group.accountType))
stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType))
}
}
/**
* The human name of the app backing [accountType], falling back to the raw
@@ -1,41 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.unit.dp
/**
* Trailing-edge and title spacing shared by the four calendar app bars (#165).
*
* Only the trailing edge and the title's start are shared; the leading edge and
* the gaps between the action icons stay on M3's defaults, which derive both
* from the icon buttons' width.
*/
object AppBarSpacing {
/** Side inset shared by the bars' trailing edge and the month grid. */
val Inset = 8.dp
/**
* Start inset on the title, taking it clear of the navigation icon M3 places
* it flush against. Real padding rather than a negative offset, which would
* swallow taps meant for the menu button.
*/
val TitleInset = 8.dp
/** M3's own padding around the actions row. */
internal val BarPadding = 4.dp
private val IconButtonSize = 48.dp
/** M3's icon slot inside an icon button; the today glyph fills the same box. */
internal val IconSize = 24.dp
/**
* End padding for a container-backed control that ends the bar, measured to
* its background. Coerced because [androidx.compose.foundation.layout.padding]
* throws on a negative value.
*/
val ContainerTrailingInset = (Inset - BarPadding).coerceAtLeast(0.dp)
/** Screen edge to the glyph of an icon button that ends the bar. */
val IconTrailingInset = BarPadding + (IconButtonSize - IconSize) / 2
}
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
@@ -11,240 +10,24 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlin.math.roundToInt
/** Gap a timed block leaves to its neighbours in the column. */
val BLOCK_OUTER_INSET = 1.dp
/** Padding between a timed block's edge and its text. */
val BLOCK_TEXT_PADDING = 4.dp
/** The same, above and below — what a block's height has to pay before any text. */
val BLOCK_TEXT_INSET = 2.dp
/**
* What a timed block of a given height has to spend on text, and what each line
* of it costs.
*/
@Immutable
data class BlockTextMetrics(
/** Padding above and below the text — see [rememberBlockTextMetrics]. */
val inset: Dp,
/** Height left for text once [inset] is paid at both edges. */
val available: Dp,
/** What the first line of a title draws in. */
val titleLine: Dp,
/** What every title line after the first adds. */
val titleLeading: Dp,
/** What the time label's one line draws in. */
val timeLine: Dp,
) {
/** Whether the block can draw a title at all. */
val fitsTitle: Boolean get() = available >= titleLine
/** Height a title of [lines] lines occupies. */
fun titleHeight(lines: Int): Dp =
if (lines <= 0) 0.dp else titleLine + titleLeading * (lines - 1)
/** Title lines that fit [within], which may be none. */
fun titleBudget(within: Dp): Int =
if (within < titleLine) 0 else 1 + ((within - titleLine) / titleLeading).toInt()
}
/**
* The vertical padding a block [height] tall can afford around a title line of
* [titleLine].
*
* The inset is what the block gives up first: breathing room is worth having
* where there is room to breathe, but on a block down to its last few pixels a
* bare colour chip where a label would have fit reads as a rendering fault. It
* tapers rather than snapping, so a pinch closes the gap gradually instead of
* dropping it in one frame (#289).
*/
internal fun blockTextInset(height: Dp, titleLine: Dp): Dp =
minOf(BLOCK_TEXT_INSET, (height - titleLine) / 2).coerceAtLeast(0.dp)
/** Text metrics for a timed block [height] tall. */
@Composable
fun rememberBlockTextMetrics(height: Dp): BlockTextMetrics {
val titleStyle = MaterialTheme.typography.labelMedium
val titleLine = rememberTrimmedLineHeight(titleStyle)
// Packed, so a second line costs what the first did rather than a whole
// Material line box — the gap between two lines of a wrapped title is the
// one place a block pays that leading twice (#190).
val titleLeading = titleLine
val timeLine = rememberTrimmedLineHeight(MaterialTheme.typography.labelSmall.asEventTime())
val inset = blockTextInset(height, titleLine)
return BlockTextMetrics(
inset = inset,
available = height - inset * 2,
titleLine = titleLine,
titleLeading = titleLeading,
timeLine = timeLine,
)
}
/**
* Lines [text] needs to render whole at [textWidth], capped at [max].
*
* Lets a block hand out its height by what the text actually asks for rather
* than by what would fit: a title that wants one line should not be given three
* that the time label could have used, and a week column is narrower than a
* "09:30–11:00" range so the range should not be assumed to want one.
*/
@Composable
fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int {
val measurer = rememberTextMeasurer()
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
return remember(text, style, widthPx, max, measurer) {
if (max <= 1 || widthPx <= 0) {
1
} else {
measurer.measure(
text = text,
style = style,
constraints = Constraints(maxWidth = widthPx),
).lineCount.coerceIn(1, max)
}
}
}
/**
* Lines the time label may take at [textWidth], out of the [spare] height left
* once the title and the label's own first line are paid for.
*
* A week column is narrower than a "09:30–11:00" range — and a 12-hour
* "6:00 PM–7:00 PM" can want four lines — so the label wraps over as many as it
* needs rather than lose its end, but only out of lines the title measured
* itself as not needing, never one it would have filled.
*/
@Composable
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
val timeLineHeight = rememberTrimmedLineHeight(
MaterialTheme.typography.labelSmall.asEventTime(),
)
return blockTextLines(
text = label,
// The style it is drawn in, or the budget measures a line the label
// never uses (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
textWidth = textWidth,
max = timeLinesWithin(spare, timeLineHeight),
)
}
/** Lines a time label may take: its own first one, plus every whole [timeLine] in [spare]. */
internal fun timeLinesWithin(spare: Dp, timeLine: Dp): Int =
if (spare < timeLine || timeLine <= 0.dp) 1 else 1 + (spare / timeLine).toInt()
/**
* The time range as a timed block draws it.
*
* Each time stays whole where the column is wide enough for one, so a 12-hour
* label never parts "6:00" from its "PM": the range breaks at the dash, which
* rides on whichever side has room for it. Where not even one time fits, the
* text wraps wherever it can.
*/
@Composable
fun rememberBlockTimeRange(start: String, end: String, textWidth: Dp): String {
val measurer = rememberTextMeasurer()
val style = MaterialTheme.typography.labelSmall.asEventTime()
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
return remember(start, end, style, widthPx, measurer) {
blockTimeRange(start, end) {
measurer.measure(text = it, style = style, softWrap = false).size.width <= widthPx
}
}
}
internal fun blockTimeRange(start: String, end: String, fits: (String) -> Boolean): String {
val from = start.replace(' ', NO_BREAK_SPACE)
val to = end.replace(' ', NO_BREAK_SPACE)
return when {
fits("$from–$to") -> "$from–$to"
!fits(from) || !fits(to) -> "$start–$ZERO_WIDTH_SPACE$end"
fits("$from–") -> "$from–\n$to"
fits("–$to") -> "$from\n–$to"
else -> "$from\n$to"
}
}
private const val NO_BREAK_SPACE = ' '
private const val ZERO_WIDTH_SPACE = '​'
/**
* A timed block's title, over at most [maxLines], breaking at word boundaries.
*
* #164 clipped the last line mid-glyph so none of a narrow chip's few
* characters went on an ellipsis. On a block that can wrap, whole words read
* better than full lines do: "Farmers Market" over two lines beats "Farmer" /
* "s Marke". A single line still clips, having nowhere to wrap to.
*/
@Composable
fun BlockTitle(
title: String,
maxLines: Int,
color: Color,
modifier: Modifier = Modifier,
textDecoration: TextDecoration? = null,
fontWeight: FontWeight? = null,
) {
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
Text(
text = title,
modifier = modifier,
style = rememberPackedLines(
MaterialTheme.typography.labelMedium
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
),
maxLines = maxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
color = color,
textDecoration = textDecoration,
)
}
/**
* A timed block's own time label, crossfaded rather than replaced — the block
* slides to its new slot, so the label shouldn't change in a single frame.
*
* Overflows like a title does (#164): the "…" costs two characters of a string
* that is nothing but characters, so the label clips at the block's edge
* instead. [maxLines] lets a narrow column spend spare height on the range
* rather than losing its end.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun BlockTimeLabel(
label: String,
color: Color,
modifier: Modifier = Modifier,
maxLines: Int = 1,
) {
fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
snap()
} else {
MaterialTheme.motionScheme.fastEffectsSpec()
}
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
// Regular weight against the title's medium above it (#219).
val style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime())
Crossfade(
targetState = label,
animationSpec = spec,
@@ -253,22 +36,17 @@ fun BlockTimeLabel(
) { text ->
Text(
text = text,
style = style,
maxLines = maxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = color,
)
}
}
/**
* Where a timed block sits in its column, after tweening: across as shares of
* the column's width, so a column resizing carries its blocks with it (#184),
* down in the timeline's own units.
*/
/** Where a timed block sits in its column, after tweening. */
@Immutable
data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y: Dp, val height: Dp)
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
/**
* A timed block's placement, tweened rather than jumped. Continuity comes from
@@ -278,63 +56,20 @@ data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y:
* A pinch-zoom rewrites the hour height every pointer frame, and the gutter and
* grid lines follow it instantly — so the tween stands down for the gesture
* rather than leaving the blocks trailing the ruler they are measured against.
* A [landing] block, under a dropped copy, moves without the spring's bounce.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun animatedBlockPlacement(
xFraction: Float,
widthFraction: Float,
y: Dp,
height: Dp,
landing: Boolean = false,
): BlockPlacement {
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
val dpSpec: FiniteAnimationSpec<Dp> = when {
still -> snap()
landing -> MaterialTheme.motionScheme.fastEffectsSpec()
else -> MaterialTheme.motionScheme.fastSpatialSpec()
}
val shareSpec: FiniteAnimationSpec<Float> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
val animatedY by animateDpAsState(y, dpSpec, label = "block-y")
val animatedHeight by animateDpAsState(height, dpSpec, label = "block-height")
return BlockPlacement(animatedX, animatedWidth, animatedY, animatedHeight)
}
/** Seats a block across its [placement]'s share of a [LaneColumn]. */
fun Modifier.laneSlot(placement: BlockPlacement): Modifier =
then(LaneSlot(placement.xFraction, placement.widthFraction))
private data class LaneSlot(val x: Float, val width: Float) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any = this@LaneSlot
}
/**
* A day column's blocks, each measured across the share of the column its
* [laneSlot] gives it; anything else fills the column. The shares are applied
* at layout time, so the column can change width every frame without its
* blocks recomposing.
*/
@Composable
fun LaneColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(content = content, modifier = modifier) { measurables, constraints ->
val width = constraints.maxWidth
val loose = constraints.copy(minWidth = 0, minHeight = 0)
val placed = measurables.map { measurable ->
val slot = measurable.parentData as? LaneSlot
if (slot == null) {
measurable.measure(loose) to 0
} else {
val left = (slot.x * width).roundToInt()
val right = ((slot.x + slot.width) * width).roundToInt()
val laneWidth = (right - left).coerceAtLeast(0)
measurable.measure(loose.copy(minWidth = laneWidth, maxWidth = laneWidth)) to left
}
}
layout(width, constraints.maxHeight) {
placed.forEach { (placeable, x) -> placeable.placeRelative(x, 0) }
}
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion() ||
LocalTimelineZoom.current.isPinching
) {
snap()
} else {
MaterialTheme.motionScheme.fastSpatialSpec()
}
val animatedX by animateDpAsState(x, spec, label = "block-x")
val animatedY by animateDpAsState(y, spec, label = "block-y")
val animatedWidth by animateDpAsState(width, spec, label = "block-width")
val animatedHeight by animateDpAsState(height, spec, label = "block-height")
return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight)
}
@@ -1,294 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
/**
* What a calendar screen shows in the top bar, the FAB and the drawer, which the
* host draws once for all of them (#184): switching screens changes what they
* say rather than swapping one bar for another.
*
* @param titleDate the date the title's picker opens on, or null for a title
* that is only a label.
* @param drawerDate the date the drawer's picker opens on.
* @param awayFromToday whether "today" is worth offering.
*/
@Immutable
data class ScreenChrome(
val title: String,
val shortTitle: String = title,
val titleDate: LocalDate?,
val drawerDate: LocalDate,
val awayFromToday: Boolean,
val todayText: String,
)
/** What the chrome does for the screen on show; read when used, so not observed. */
class ScreenActions(
val onToday: () -> Unit,
val onJumpToDate: (LocalDate) -> Unit,
val onCreate: () -> Unit,
)
/**
* An event a long press left selected (#141), which the host answers with the
* action bar in place of the FAB. [dismiss] clears it on the screen that owns it.
* [create] is set for a block drafted on an empty slot (#153), which has no
* event yet, so the bar offers to create one.
*/
@Stable
class EventSelection(
val event: EventInstance,
val dismiss: () -> Unit,
val create: (() -> Unit)? = null,
/** The event at the times an ongoing move or resize would give it, else null. */
val preview: () -> EventInstance? = { null },
)
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
@Stable
class ChromeHost {
var chrome by mutableStateOf<ScreenChrome?>(null)
private set
var selection by mutableStateOf<EventSelection?>(null)
internal set
/** Whether the selected event is being dragged or resized right now. */
var selectionBusy by mutableStateOf(false)
internal set
/** Whether the bar would cover the selected event, and so draws compact. */
var selectionCompact by mutableStateOf(false)
internal set
var actions: ScreenActions? = null
private set
internal fun publish(chrome: ScreenChrome, actions: ScreenActions) {
this.chrome = chrome
this.actions = actions
}
}
/** The host's chrome, and whether the screen reading it is the one on show rather than one leaving. */
class ChromeSlot(val host: ChromeHost, val current: Boolean)
val LocalChromeSlot = staticCompositionLocalOf<ChromeSlot?> { null }
/** Hands [chrome] and [actions] to the host, while this screen is the one on show. */
@Composable
fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
val slot = LocalChromeSlot.current ?: return
if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
}
/** Hands the screen's selected [event] to the host, for as long as it stays selected. */
@Composable
fun PublishSelection(
event: EventInstance?,
busy: Boolean,
compact: Boolean,
onDismiss: () -> Unit,
onCreate: (() -> Unit)? = null,
preview: () -> EventInstance? = { null },
) {
val slot = LocalChromeSlot.current ?: return
val currentDismiss by rememberUpdatedState(onDismiss)
val currentCreate by rememberUpdatedState(onCreate)
val currentPreview by rememberUpdatedState(preview)
val drafted = onCreate != null
val selection = remember(event, drafted) {
event?.let {
EventSelection(
event = it,
dismiss = { currentDismiss() },
create = if (drafted) ({ currentCreate?.invoke() }) else null,
preview = { currentPreview() },
)
}
}
val host = slot.host
val current = slot.current
DisposableEffect(host, selection, current) {
if (current) host.selection = selection
onDispose {
if (host.selection === selection) host.selection = null
// Left mid-drag, nothing else would clear these for the other views.
host.selectionBusy = false
host.selectionCompact = false
}
}
if (current) {
SideEffect {
host.selectionBusy = busy && selection != null
host.selectionCompact = compact && selection != null
}
}
}
/**
* The top bar every calendar screen shares — or, while an event is selected,
* the selection's own bar in its place (#141).
*/
@Composable
fun CalendarTopBar(
host: ChromeHost,
selectedView: CalendarView,
quickSwitchViews: List<CalendarView>,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
) {
val effects = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
AnimatedContent(
targetState = host.selection,
contentKey = { it != null },
transitionSpec = {
(fadeIn(effects) + slideInVertically { -it / 4 })
.togetherWith(fadeOut(effects) + slideOutVertically { -it / 4 })
},
label = "top-bar",
) { selection ->
if (selection != null) {
SelectionTopBar(selection)
} else {
ViewTopBar(
host = host,
selectedView = selectedView,
quickSwitchViews = quickSwitchViews,
onCycleView = onCycleView,
onOpenDrawer = onOpenDrawer,
onOpenSearch = onOpenSearch,
showTodayButton = showTodayButton,
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ViewTopBar(
host: ChromeHost,
selectedView: CalendarView,
quickSwitchViews: List<CalendarView>,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
) {
val chrome = host.chrome
TopAppBar(
title = {
if (chrome != null) {
CalendarTitleButton(
title = chrome.title,
currentDate = chrome.titleDate,
onJumpToDate = { host.actions?.onJumpToDate?.invoke(it) },
shortTitle = chrome.shortTitle,
)
}
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
TodayAction(show = showTodayButton, onToday = { host.actions?.onToday?.invoke() })
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
)
},
// Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
}
/**
* The FAB column every calendar screen shares. While an event is selected the
* FAB is [selection]'s toggle instead, and it steps aside mid-gesture.
*/
@Composable
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean, selection: FabSelection? = null) {
val chrome = host.chrome ?: return
AnimatedVisibility(
visible = !host.selectionBusy,
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(),
) {
CalendarFabColumn(
todayVisible = chrome.awayFromToday && !todayInToolbar,
todayText = chrome.todayText,
onToday = { host.actions?.onToday?.invoke() },
onCreate = { host.actions?.onCreate?.invoke() },
selection = selection,
)
}
}
/**
* A calendar screen's body under the shared chrome, on the ground the screen's
* own Scaffold used to give it; the host's Scaffold has already made room for
* the bars, so [content] gets no padding of its own.
*/
@Composable
fun ScreenBody(modifier: Modifier = Modifier, content: @Composable (PaddingValues) -> Unit) {
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
content(PaddingValues())
}
}
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
@@ -13,7 +14,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
/**
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
@@ -23,7 +23,7 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
*/
@Composable
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
val dark = LocalFloretDarkTheme.current
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
Box(
modifier = modifier
@@ -91,7 +91,7 @@ fun CalendarDrawer(
DrawerSectionHeader(stringResource(R.string.view_section))
viewOrder.forEachIndexed { index, view ->
GroupedRow(
title = view.label(),
title = stringResource(view.labelRes),
position = positionOf(index, viewOrder.size),
selected = view == currentView,
minHeight = 56.dp,
@@ -1,20 +1,11 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -24,38 +15,15 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
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.graphics.graphicsLayer
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/** The standard FAB's size, shared by the selection bar that unrolls from it (#141). */
val FabSize = 56.dp
/** What the FAB is while an event is selected (#141): the toggle for its action bar. */
@Stable
class FabSelection(
val expanded: Boolean,
val onToggle: () -> Unit,
val bar: @Composable () -> Unit,
)
/**
* The FAB stack shared by the three calendar views: a persistent "+" to
* create an event, with the jump-to-today pill appearing above it whenever
* the view isn't anchored on today. While an event is selected the same FAB,
* in the same spot, opens and folds the selection's action bar, which unrolls
* from it towards the start.
* the view isn't anchored on today.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -64,14 +32,13 @@ fun CalendarFabColumn(
todayText: String,
onToday: () -> Unit,
onCreate: () -> Unit,
selection: FabSelection? = null,
) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
AnimatedVisibility(
visible = todayVisible && selection == null,
visible = todayVisible,
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
) {
@@ -81,71 +48,11 @@ fun CalendarFabColumn(
text = { Text(todayText) },
)
}
// Held past the selection clearing, so the bar has something to fold away.
val heldBar = remember { mutableStateOf<(@Composable () -> Unit)?>(null) }
if (selection != null && heldBar.value !== selection.bar) heldBar.value = selection.bar
// Held at the FAB's height so the taller bar can't shift the FAB.
Row(
modifier = Modifier.height(FabSize),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AnimatedVisibility(
visible = selection?.expanded == true,
enter = expandHorizontally(
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
expandFrom = Alignment.End,
) + fadeIn(),
exit = shrinkHorizontally(
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
shrinkTowards = Alignment.End,
) + fadeOut(),
) {
heldBar.value?.invoke()
}
val label = stringResource(
when {
selection == null -> R.string.event_edit_new_title
selection.expanded -> R.string.event_selection_hide
else -> R.string.event_selection_actions
},
FloatingActionButton(onClick = onCreate) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.event_edit_new_title),
)
FloatingActionButton(
onClick = selection?.onToggle ?: onCreate,
modifier = Modifier.semantics { contentDescription = label },
) {
FabGlyph(selecting = selection != null, expanded = selection?.expanded == true)
}
}
}
}
/** "+", or with an event selected a chevron that turns to open and fold the bar. */
@Composable
private fun FabGlyph(selecting: Boolean, expanded: Boolean) {
val spatial = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
val swap by animateFloatAsState(if (selecting) 1f else 0f, spatial, label = "fab-swap")
val turn by animateFloatAsState(if (expanded) 0f else 180f, spatial, label = "fab-turn")
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.graphicsLayer {
rotationZ = 90f * swap
scaleX = 1f - 0.5f * swap
scaleY = 1f - 0.5f * swap
alpha = (1f - swap).coerceIn(0f, 1f)
},
)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.graphicsLayer {
rotationZ = turn - 90f * (1f - swap)
scaleX = 0.5f + 0.5f * swap
scaleY = 0.5f + 0.5f * swap
alpha = swap.coerceIn(0f, 1f)
},
)
}
}
@@ -1,40 +1,17 @@
package de.jeanlucmakiola.calendula.ui.common
import android.content.Context
import android.content.Intent
import android.provider.CalendarContract
import android.provider.Settings
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.EditOff
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.EventBusy
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.SyncProblem
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -43,168 +20,37 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
/**
* Full-screen failure state shared by every calendar screen (spec §7).
* A tonal icon, one headline, one supporting line and one recovery action —
* never a toast.
* One explanation line + one recovery action, never a toast.
*/
@Composable
fun CalendarFailure(
reason: FailureReason,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val manageCalendars = LocalManageCalendars.current
val copy = failureCopy(reason)
val onAction: () -> Unit = when (reason) {
FailureReason.NoCalendarsConfigured -> {
{ context.startCalendarSetup() }
}
FailureReason.AllCalendarsHidden,
FailureReason.NoImportTarget,
-> manageCalendars ?: onRetry
else -> onRetry
fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
val titleRes = when (reason) {
FailureReason.PermissionRevoked -> R.string.state_failure_permission
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
FailureReason.ProviderUnavailable -> R.string.state_failure_provider
FailureReason.Unknown,
FailureReason.EventNotFound -> R.string.state_failure_unknown
}
val actionRes = when (reason) {
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
else -> R.string.state_retry
}
Column(
modifier = modifier
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp, vertical = 24.dp),
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
FailureIcon(icon = copy.icon, isError = copy.isError)
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(copy.title),
text = stringResource(titleRes),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
)
copy.body?.let { body ->
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
)
}
Spacer(Modifier.height(32.dp))
FilledTonalButton(onClick = onAction) {
Text(stringResource(copy.action))
Spacer(Modifier.height(24.dp))
FilledTonalButton(onClick = onRetry) {
Text(stringResource(actionRes))
}
}
}
/** The state's mark: the reason's icon in a tonal circle above the headline. */
@Composable
private fun FailureIcon(icon: ImageVector, isError: Boolean) {
val container: Color
val content: Color
if (isError) {
container = MaterialTheme.colorScheme.errorContainer
content = MaterialTheme.colorScheme.onErrorContainer
} else {
container = MaterialTheme.colorScheme.secondaryContainer
content = MaterialTheme.colorScheme.onSecondaryContainer
}
Box(
modifier = Modifier
.size(72.dp)
.background(container, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = content,
modifier = Modifier.size(36.dp),
)
}
}
/**
* What a reason says and offers. Headline and supporting line are separate so
* the explanation reads as body text instead of a headline that runs over three
* lines; [isError] tints the mark for the two reasons that are an actual fault
* rather than a calendar the user can switch back on.
*/
private data class FailureCopy(
val icon: ImageVector,
@StringRes val title: Int,
@StringRes val body: Int?,
@StringRes val action: Int,
val isError: Boolean = false,
)
private fun failureCopy(reason: FailureReason): FailureCopy = when (reason) {
FailureReason.PermissionRevoked -> FailureCopy(
icon = Icons.Outlined.Lock,
title = R.string.state_failure_permission,
body = R.string.state_failure_permission_body,
action = R.string.state_failure_permission_action,
)
FailureReason.NoCalendarsConfigured -> FailureCopy(
icon = Icons.Outlined.CalendarMonth,
title = R.string.state_failure_no_calendars,
body = R.string.state_failure_no_calendars_body,
action = R.string.state_failure_no_calendars_action,
)
FailureReason.AllCalendarsHidden -> FailureCopy(
icon = Icons.Outlined.VisibilityOff,
title = R.string.state_failure_all_hidden,
body = R.string.state_failure_all_hidden_body,
action = R.string.state_failure_all_hidden_action,
)
FailureReason.NoImportTarget -> FailureCopy(
icon = Icons.Outlined.EditOff,
title = R.string.state_failure_no_import_target,
body = R.string.state_failure_no_import_target_body,
action = R.string.state_failure_all_hidden_action,
)
FailureReason.EventNotFound -> FailureCopy(
icon = Icons.Outlined.EventBusy,
title = R.string.state_failure_event_not_found,
body = R.string.state_failure_event_not_found_body,
action = R.string.state_retry,
)
FailureReason.ProviderUnavailable -> FailureCopy(
icon = Icons.Outlined.SyncProblem,
title = R.string.state_failure_provider,
body = R.string.state_failure_provider_body,
action = R.string.state_retry,
isError = true,
)
FailureReason.Unknown -> FailureCopy(
icon = Icons.Outlined.ErrorOutline,
title = R.string.state_failure_unknown,
body = null,
action = R.string.state_retry,
isError = true,
)
}
/** Keeps the headline and its supporting line at a readable measure. */
private val TEXT_MAX_WIDTH = 320.dp
/**
* Opens Settings → Calendars, the only screen that can switch a calendar back
* on. Null outside the calendar host — screens without it never raise the
* reason that needs it, and fall back to their own retry.
*/
val LocalManageCalendars = compositionLocalOf<(() -> Unit)?> { null }
/**
* Open the system screen for putting a calendar on the device: the account
* chooser filtered to calendar providers, falling back to account settings
* where no chooser resolves. Both are system components, so nothing here needs
* a `<queries>` entry.
*/
private fun Context.startCalendarSetup() {
val intents = listOf(
Intent(Settings.ACTION_ADD_ACCOUNT)
.putExtra(Settings.EXTRA_AUTHORITIES, arrayOf(CalendarContract.AUTHORITY)),
Intent(Settings.ACTION_SYNC_SETTINGS),
)
intents.firstOrNull { runCatching { startActivity(it) }.isSuccess }
}
@@ -1,57 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.VectorConverter
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.lerp
import kotlinx.coroutines.flow.collectLatest
import kotlin.math.floor
/**
* One height for every page of something that varies from page to page — the
* timelines' all-day strip, the split grid's rows — blended between the two
* pages a swipe sits between, so what sits below it moves with the finger rather
* than jumping on settle. It follows the swipe directly and only springs when a
* page's own height changes at rest. [fallback] stands in until the pages have
* reported theirs, so it doesn't open from zero.
*
* @param heights each composed page's own height, by page index.
*/
@Composable
fun pagedHeight(pagerState: PagerState, heights: Map<Int, Dp>, fallback: Dp): Dp {
val currentFallback by rememberUpdatedState(fallback)
val target by remember(pagerState, heights) {
derivedStateOf {
val position = pagerState.currentPage + pagerState.currentPageOffsetFraction
val from = floor(position).toInt()
val a = heights[from]
val b = heights[from + 1]
lerp(a ?: b ?: currentFallback, b ?: a ?: currentFallback, position - from)
}
}
val height = remember { Animatable(target, Dp.VectorConverter) }
LaunchedEffect(height) {
snapshotFlow { target to pagerState.isScrollInProgress }.collectLatest { (h, scrolling) ->
if (scrolling) height.snapTo(h) else height.animateTo(h)
}
}
return height.value
}
/** A deliberate jump (Today, jump-to-date): animated, or snapped under reduced motion. */
suspend fun PagerState.jumpToPage(
page: Int,
reduceMotion: Boolean,
spec: FiniteAnimationSpec<Float>,
) {
if (reduceMotion) scrollToPage(page) else animateScrollToPage(page, animationSpec = spec)
}
@@ -34,6 +34,7 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
/**
* The app's single "which calendar" selection list, shared by the event editor
@@ -113,11 +114,17 @@ private fun CalendarPickerGroup(
leading = leading,
)
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id == selectedId
GroupedRow(
title = calendar.displayName.orUnnamed(),
title = calendar.displayName,
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
checked = calendar.id == selectedId,
selected = isSelected,
leading = { CalendarColorChip(calendar.color) },
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(calendar.id) },
)
}
@@ -0,0 +1,79 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
/**
* Drag distance that turns a calendar page, shared by the month, week and day
* views so all three answer a swipe at the same point.
*
* It was 6dp once, which is inside the distance a tap wanders: brushing the grid
* changed the month, and a page that turns on an unintended gesture reads as the
* animation misfiring rather than as the gesture being over-eager.
*/
val CALENDAR_SWIPE_THRESHOLD = 24.dp
/**
* The whole-page horizontal swipe: one page per gesture, committed **the moment
* the drag clears [CALENDAR_SWIPE_THRESHOLD]** rather than when the finger lifts.
*
* Waiting for the lift meant the page sat still under a finger that had already
* travelled far enough to ask for it, and the answer only arrived once you let
* go — which reads as the view being slow rather than as a deliberate commit.
* Firing on the threshold is what makes the gesture feel like it is being
* followed. The trade is that a drag can no longer be taken back by dragging the
* other way; in practice, once you have moved 24dp deliberately you meant it, and
* the page you land on is one swipe back.
*
* Deliberately **horizontal-only**. The week and day timelines scroll vertically
* underneath this, and a two-dimensional detector here would claim those drags
* before the inner scroll ever saw them. As it is, a horizontal drag crosses this
* detector's slop while a vertical one is consumed below, and the two coexist.
* (The month view's split style needs a vertical axis as well, so it keeps its
* own axis-locking detector rather than using this.)
*/
@Composable
fun rememberCalendarPageSwipe(
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
): Modifier {
val threshold = with(LocalDensity.current) { CALENDAR_SWIPE_THRESHOLD.toPx() }
return Modifier.pointerInput(onSwipeNext, onSwipePrev) {
var accum = 0f
// One page per gesture: without this a long drag would keep re-firing
// every time the accumulator crossed the threshold again.
var fired = false
detectHorizontalDragGestures(
onDragStart = {
accum = 0f
fired = false
},
onDragEnd = {
accum = 0f
fired = false
},
onDragCancel = {
accum = 0f
fired = false
},
onHorizontalDrag = { _, drag ->
accum += drag
if (!fired) {
val commit = when {
accum < -threshold -> onSwipeNext
accum > threshold -> onSwipePrev
else -> null
}
if (commit != null) {
fired = true
commit()
}
}
},
)
}
}
@@ -1,63 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.R
/**
* The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in
* a 16sp line box — room a label wants when it stands on its own, and close to
* a fifth of an event row when it doesn't. The outer edges only, so a wrapped
* title keeps its interior line spacing (#190).
*
* Trimmed rather than set to a smaller line height: the font picker can load a
* serif, a monospace or a file of the user's own, and a line height under a
* face's own ascent and descent overlaps its lines. There is nothing to trim
* below that, so this is safe whatever font is chosen.
*/
private val TrimmedLines = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.Both,
)
/** Ascenders and descenders both, so a line is measured at its full extent. */
private const val LINE_SAMPLE = "Ag"
/** [this] with Material's outer leading trimmed — see [TrimmedLines]. */
fun TextStyle.trimmedLines(): TextStyle = copy(lineHeightStyle = TrimmedLines)
/**
* [style] with its wrapped lines packed onto the face's own extent instead of
* Material's line box.
*
* [trimmedLines] takes the leading off the outer edges of a run of text; this
* takes it from between the lines as well, which is the half a wrapped event
* title pays for twice over. The line height is *measured* from the font rather
* than picked, so it lands exactly on the face's ascent-plus-descent and can
* never be short enough to overlap — whatever the font picker has loaded (#190).
*/
@Composable
fun rememberPackedLines(style: TextStyle): TextStyle {
val line = rememberTrimmedLineHeight(style)
return with(LocalDensity.current) { style.trimmedLines().copy(lineHeight = line.toSp()) }
}
/** What one trimmed line of [style] actually draws in. */
@Composable
fun rememberTrimmedLineHeight(style: TextStyle): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
return remember(style, density, measurer) {
with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() }
}
}
/** A calendar or account name, or the "unnamed" label when it is blank. */
@Composable
fun String.orUnnamed(): String = ifBlank { stringResource(R.string.calendar_unnamed) }
@@ -1,15 +1,6 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
@@ -18,19 +9,16 @@ import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalDate
@@ -41,20 +29,19 @@ import kotlinx.datetime.LocalDate
* drawer uses and reporting the chosen day through [onJumpToDate].
*
* [currentDate] seeds the picker with whatever the bar is currently naming (the
* visible day, week start or month anchor); null makes the title a plain label,
* as the agenda's is, and the caret folds away.
* visible day, week start or month anchor).
*
* [shortTitle] replaces [title] when the full one does not fit the width the app
* bar hands the title slot (#165). Either way the line clamps to one and
* ellipsises, so pass a [shortTitle] wherever there is something left to drop.
* The row keeps its own 8.dp inset rather than shifting back onto the app bar's
* start alignment: M3 places the title flush against the navigation icon's
* trailing edge, and the title is hit-tested on top of it, so a negative offset
* would swallow taps meant for the menu button.
*/
@Composable
fun CalendarTitleButton(
title: String,
currentDate: LocalDate?,
currentDate: LocalDate,
onJumpToDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
shortTitle: String = title,
) {
var showDatePicker by rememberSaveable { mutableStateOf(false) }
@@ -63,56 +50,25 @@ fun CalendarTitleButton(
.heightIn(min = 48.dp)
.clip(MaterialTheme.shapes.large)
.clickable(
enabled = currentDate != null,
onClickLabel = stringResource(R.string.drawer_jump_to_date),
role = Role.Button,
) { showDatePicker = true }
.padding(horizontal = AppBarSpacing.TitleInset),
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val style = MaterialTheme.typography.titleLarge
BoxWithConstraints(modifier = Modifier.weight(1f, fill = false)) {
val measurer = rememberTextMeasurer()
val shown = remember(title, shortTitle, style, constraints.maxWidth, measurer) {
titleFor(
title = title,
shortTitle = shortTitle,
titleWidth = measurer.measure(title, style).size.width,
availableWidth = constraints.maxWidth,
)
}
// Cross-fades, so switching views or paging turns the title over
// rather than snapping it (#184).
val fadeSpec = rememberCalendarFadeSpec()
AnimatedContent(
targetState = shown,
transitionSpec = {
(fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false))
},
contentAlignment = Alignment.CenterStart,
) { text ->
Text(
text = text,
style = style,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
AnimatedVisibility(
visible = currentDate != null,
enter = fadeIn(rememberCalendarFadeSpec()) + expandHorizontally(),
exit = fadeOut(rememberCalendarFadeSpec()) + shrinkHorizontally(),
) {
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f, fill = false),
)
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (showDatePicker && currentDate != null) {
if (showDatePicker) {
CalendarDatePickerDialog(
initial = currentDate,
onConfirm = {
@@ -123,11 +79,3 @@ fun CalendarTitleButton(
)
}
}
/** Falls back to [shortTitle] when [title] is wider than [availableWidth] (#165). */
internal fun titleFor(
title: String,
shortTitle: String,
titleWidth: Int,
availableWidth: Int,
): String = if (titleWidth <= availableWidth) title else shortTitle
@@ -1,7 +1,13 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -13,12 +19,12 @@ import androidx.compose.ui.unit.IntOffset
* the reduce-motion check — now live in floret-kit's identity module
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
* here is only what's specific to paging the calendar grid: the specs its
* pagers settle with, and that the view switch reuses.
* here is only what's specific to paging the calendar grid: the directional
* month/week/day slide and the specs that feed it.
*/
/**
* The M3 Expressive spatial spring the calendar's motion is built on: the
* The M3 Expressive spatial spring used for the month/week/day slide: the
* *default* spring-physics spec from the active motion scheme, rather than a
* fixed easing curve.
*
@@ -34,12 +40,6 @@ import androidx.compose.ui.unit.IntOffset
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.defaultSpatialSpec()
/** The same spring as [rememberCalendarSlideSpec], for a pager settling onto its page. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberCalendarPageSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.defaultSpatialSpec()
/**
* The effects spec from the active motion scheme, for the opacity half of the
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
@@ -55,17 +55,48 @@ fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.defaultEffectsSpec()
/**
* A switch between views (#184): the default spatial spring, a little softer, as
* the whole screen reshapes rather than a page sliding by; softer still when
* [long], for a change the eye has further to follow.
* Navigating between adjacent months/weeks/days, as M3's shared-axis X: the
* outgoing page slides and fades one way while the incoming one arrives from the
* other, position on a spring and opacity on an easing curve.
*
* @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 opacity half, and for the whole
* transition under reduced motion; typically
* [rememberCalendarFadeSpec].
* @param reduceMotion when true, drop the movement and cross-fade alone.
*/
@Composable
fun <T> rememberViewSwitchSpec(long: Boolean = false): FiniteAnimationSpec<T> =
spring(
dampingRatio = VIEW_SWITCH_DAMPING,
stiffness = if (long) LONG_VIEW_SWITCH_STIFFNESS else VIEW_SWITCH_STIFFNESS,
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 ContentTransform(
targetContentEnter =
slideInHorizontally(spec) { w -> dir * w / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec),
initialContentExit =
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
// AnimatedContent clips to the animating container by default, which
// shears the pages against the viewport edge as they pass. There is no
// size change here to contain — both pages are the same grid.
sizeTransform = SizeTransform(clip = false),
)
}
private const val VIEW_SWITCH_DAMPING = 0.9f
private const val VIEW_SWITCH_STIFFNESS = 450f
private const val LONG_VIEW_SWITCH_STIFFNESS = 340f
/**
* How far a page travels, as a fraction of the container width.
*
* A full width was the obvious reading of "paging", but the two pages are
* stacked and both opaque, so a full-width slide showed one grid racing across
* another — the movement carried the whole transition and had a long way to go.
* Under M3's shared-axis pattern the offset only has to *hint* the direction
* while the cross-fade does the swapping, so a fifth of the width is plenty and
* leaves nothing skating past.
*/
private const val SLIDE_TRAVEL_DIVISOR = 5
@@ -1,53 +1,37 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarViewDay
import androidx.compose.material.icons.filled.CalendarViewMonth
import androidx.compose.material.icons.filled.CalendarViewWeek
import androidx.compose.material.icons.filled.ViewAgenda
import androidx.compose.material.icons.filled.ViewColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
/** The top-level calendar views the user can switch between (spec M1). */
enum class CalendarView {
Month,
Week,
MultiDay,
Day,
Agenda,
}
/** How many days the multi-day view shows (#193), provided once at the app root. */
val LocalMultiDayCount = staticCompositionLocalOf { SettingsPrefs.DEFAULT_MULTI_DAY_COUNT }
/**
* The view's name, shared by the top-bar pill, the drawer and Settings. The
* multi-day view is named after its day count ("3 days").
*/
@Composable
fun CalendarView.label(): String = when (this) {
CalendarView.Month -> stringResource(R.string.view_month)
CalendarView.Week -> stringResource(R.string.view_week)
CalendarView.MultiDay -> {
val count = LocalMultiDayCount.current
pluralStringResource(R.plurals.view_multi_day, count, count)
/** Switcher label, shared by the top-bar pill and the drawer's View section. */
@get:StringRes
val CalendarView.labelRes: Int
get() = when (this) {
CalendarView.Month -> R.string.view_month
CalendarView.Week -> R.string.view_week
CalendarView.Day -> R.string.view_day
CalendarView.Agenda -> R.string.view_agenda
}
CalendarView.Day -> stringResource(R.string.view_day)
CalendarView.Agenda -> stringResource(R.string.view_agenda)
}
/** Leading icon for the view in the drawer's View section. */
val CalendarView.icon: ImageVector
get() = when (this) {
CalendarView.Month -> Icons.Filled.CalendarViewMonth
CalendarView.Week -> Icons.Filled.CalendarViewWeek
CalendarView.MultiDay -> Icons.Filled.ViewColumn
CalendarView.Day -> Icons.Filled.CalendarViewDay
CalendarView.Agenda -> Icons.Filled.ViewAgenda
}
@@ -57,13 +41,7 @@ val CalendarView.icon: ImageVector
* through these in order.
*/
val IMPLEMENTED_VIEWS: List<CalendarView> =
listOf(
CalendarView.Month,
CalendarView.Week,
CalendarView.MultiDay,
CalendarView.Day,
CalendarView.Agenda,
)
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day, CalendarView.Agenda)
/** Next view in [available], wrapping around. Falls back to Month if absent. */
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
@@ -79,8 +57,7 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
* implemented view — the settings screen reorders the whole set — while [cycle]
* is the subset the pill actually steps through, in [order]. The navigation
* drawer keeps its own separate order and always lists every view, so a view
* disabled here stays reachable there — including when [cycle] is emptied and
* the pill disappears altogether.
* disabled here stays reachable there.
*/
data class QuickSwitchConfig(
val order: List<CalendarView>,
@@ -90,15 +67,14 @@ data class QuickSwitchConfig(
val cycle: List<CalendarView> get() = order.filter { it in enabled }
companion object {
/**
* Fewest views that keep the switch meaningful. A single target is not a
* switch, so below this the pill is hidden rather than special-cased (#150);
* the drawer still reaches every view.
*/
const val MIN_CYCLE = 2
/** All views, in default order, all enabled. */
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
/**
* Fewest views that keep the switch meaningful — a "switch" needs at
* least two targets, so the settings screen blocks disabling below this.
*/
const val MIN_ENABLED = 2
}
}
@@ -129,16 +105,3 @@ fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarVie
/** 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
/** The screens the views are drawn by: the day, multi-day and week views share one timeline (#184). */
enum class ViewScreen { Month, Timeline, Agenda }
val CalendarView.screen: ViewScreen
get() = when (this) {
CalendarView.Month -> ViewScreen.Month
CalendarView.Agenda -> ViewScreen.Agenda
CalendarView.Week, CalendarView.MultiDay, CalendarView.Day -> ViewScreen.Timeline
}
/** Push the week view over the month after a week-number tap. */
fun List<CalendarView>.drillToWeek(): List<CalendarView> =
if (lastOrNull() == CalendarView.Week) this else this + CalendarView.Week
@@ -1,24 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import de.jeanlucmakiola.calendula.R
/** The per-row switch writing the device's own `Calendars.VISIBLE`, as Settings does (#75). */
@Composable
fun CalendarVisibilitySwitch(
calendarName: String,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
Switch(
checked = enabled,
onCheckedChange = onToggle,
modifier = Modifier.semantics { contentDescription = label },
)
}
@@ -1,129 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import android.content.ClipData
import android.os.Build
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.onLongClick
import androidx.compose.ui.semantics.semantics
import de.jeanlucmakiola.calendula.R
import kotlinx.coroutines.launch
/** Puts one labelled field on the clipboard. */
fun interface FieldCopier {
operator fun invoke(label: String, text: String)
}
/**
* A [FieldCopier] that confirms the copy only where the system doesn't (#195).
*
* Android 13 raises its own clipboard chip for every copy, so a snackbar on top
* of it reads as the app having done the job twice. A failure is always worth a
* word, though: the clipboard can refuse a very long description outright.
*/
@Composable
fun rememberFieldCopier(snackbarHostState: SnackbarHostState): FieldCopier {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val confirmation = stringResource(R.string.field_copied)
val failure = stringResource(R.string.field_copy_failed)
return remember(clipboard, scope, snackbarHostState, confirmation, failure) {
FieldCopier { label, text ->
scope.launch {
val message = runCatching {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, text)))
}.fold(
onSuccess = {
confirmation.takeIf {
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
}
},
onFailure = { failure },
)
if (message != null) {
// Copying twice in a row shouldn't queue two four-second
// confirmations — the newest one wins.
snackbarHostState.currentSnackbarData?.dismiss()
snackbarHostState.showSnackbar(message)
}
}
}
}
}
/**
* Long-press to copy [text] whole, filed on the clipboard under [label].
*
* [onTap] carries a field's existing tap action through, announced as
* [onTapLabel]; a field without one stays unclickable rather than growing a
* ripple that leads nowhere. The long press is announced through semantics,
* merging the node it sits on, so a screen reader lands on the field itself
* and finds the action there — a bare container is never focused.
*
* The press is caught ahead of the field's children, so a long press on a
* link inside the text copies too instead of leaving that stretch dead.
*/
@Composable
fun Modifier.copyOnLongPress(
label: String,
text: String,
copy: FieldCopier,
onTap: (() -> Unit)? = null,
onTapLabel: String? = null,
): Modifier {
val actionLabel = stringResource(R.string.field_copy_action)
val haptics = LocalHapticFeedback.current
return longPressAheadOfChildren(label, text, copy) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
copy(label, text)
}
.then(if (onTap != null) Modifier.clickable(onClickLabel = onTapLabel, onClick = onTap) else Modifier)
.semantics(mergeDescendants = true) {
onLongClick(actionLabel) {
copy(label, text)
true
}
}
}
/**
* Run [onLongPress] once a press has held still for the long-press timeout,
* watching on the initial pass — before the children see the events — and
* then swallow the rest of that press, so neither a link under the finger nor
* a tap handler fires on release.
*/
private fun Modifier.longPressAheadOfChildren(vararg keys: Any?, onLongPress: () -> Unit): Modifier =
pointerInput(*keys) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
val endedEarly = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
while (true) {
val change = awaitPointerEvent(PointerEventPass.Initial).changes
.firstOrNull { it.id == down.id }
val moved = change != null &&
(change.position - down.position).getDistance() > viewConfiguration.touchSlop
if (change == null || !change.pressed || moved) break
}
true
}
if (endedEarly != null) return@awaitEachGesture
onLongPress()
do {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
} while (event.changes.any { it.pressed })
}
}
@@ -1,22 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.toInstant
import kotlin.time.Instant
/** Instant range from the start of [first] through the last second of [last] in [zone]. */
internal fun dayRange(first: LocalDate, last: LocalDate, zone: TimeZone): ClosedRange<Instant> =
first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone)
/** Beginning of the week (at [weekStart]) that contains this date. */
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
return minus(offset, DateTimeUnit.DAY)
}
@@ -1,205 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import android.Manifest
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.components.OptionCard
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock
/**
* Serialise [detail] to a cache `.ics` and return a shareable content Uri for an
* `ACTION_SEND`, or null when the write fails. Exported as a one-off (see
* [toShareIcsEvent]). Blocking — call it off the main thread.
*/
fun IcsExporter.stageEventShare(detail: EventDetail): Uri? = runCatching {
val ics = IcsWriter().writeCalendar(
events = listOf(detail.toShareIcsEvent()),
dtStamp = Clock.System.now(),
)
stageShareFile(shareFileName(detail.instance.title), ics)
}.getOrNull()
/** Open the system chooser for a staged `.ics`; false when nothing could take it. */
fun Context.sendEventShare(uri: Uri, chooserTitle: String): Boolean = runCatching {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/calendar"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(send, chooserTitle))
}.isSuccess
/** Delete an event, or as much of its series as [scope] reaches from the occurrence at [beginMillis]. */
suspend fun CalendarRepository.deleteEvent(
eventId: Long,
beginMillis: Long,
scope: RecurringWriteScope,
) = when (scope) {
RecurringWriteScope.AllEvents -> deleteEvent(eventId)
RecurringWriteScope.ThisEvent -> deleteOccurrence(eventId, beginMillis)
RecurringWriteScope.ThisAndFollowing -> deleteEventFromOccurrence(eventId, beginMillis)
}
/** How a [tryDeleteEvent] ended. */
enum class DeleteOutcome { Deleted, Gone, NeedsPermission, Failed }
/** Whether this install may read the calendar at all. */
fun Context.canReadCalendar(): Boolean =
hasPermission(Manifest.permission.READ_CALENDAR)
/** Whether this install may write to the calendar; v1.0 installs only hold read access. */
fun Context.canWriteCalendar(): Boolean =
hasPermission(Manifest.permission.WRITE_CALENDAR)
/** [deleteEvent], with the provider's failures mapped to a [DeleteOutcome]. */
suspend fun CalendarRepository.tryDeleteEvent(
eventId: Long,
beginMillis: Long,
scope: RecurringWriteScope,
): DeleteOutcome = try {
deleteEvent(eventId, beginMillis, scope)
DeleteOutcome.Deleted
} catch (e: CancellationException) {
throw e
} catch (e: SecurityException) {
DeleteOutcome.NeedsPermission
} catch (e: NoSuchEventException) {
DeleteOutcome.Gone
} catch (e: Exception) {
Log.w("EventActions", "Delete failed (scope=$scope, eventId=$eventId)", e)
DeleteOutcome.Failed
}
/**
* Runs an action that writes to the calendar, asking for `WRITE_CALENDAR` first
* where the install only holds read access (v1.0 installs). A grant continues
* straight into the action that asked for it.
*/
@Composable
fun rememberWriteGate(): (() -> Unit) -> Unit {
val gate = rememberWriteGateOr()
return { action -> gate(action) {} }
}
/** [rememberWriteGate], running the fallback instead when the user refuses. */
@Composable
fun rememberWriteGateOr(): (action: () -> Unit, fallback: () -> Unit) -> Unit {
val context = LocalContext.current
var pending by remember { mutableStateOf<Pair<() -> Unit, () -> Unit>?>(null) }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
pending?.let { (action, fallback) -> if (granted) action() else fallback() }
pending = null
}
return { action, fallback ->
if (context.canWriteCalendar()) {
action()
} else {
pending = action to fallback
launcher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
}
/**
* Delete confirmation. Recurring events choose between cancelling just the
* tapped occurrence (default), truncating the series from it onwards, and
* removing the whole series.
*/
@Composable
fun DeleteEventDialog(
isRecurring: Boolean,
onConfirm: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
) {
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(
if (isRecurring) R.string.event_delete_recurring_title
else R.string.event_delete_title,
),
)
},
text = {
if (isRecurring) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { scope = RecurringWriteScope.ThisEvent },
selected = scope == RecurringWriteScope.ThisEvent,
)
OptionCard(
label = stringResource(R.string.event_delete_option_following),
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
selected = scope == RecurringWriteScope.ThisAndFollowing,
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { scope = RecurringWriteScope.AllEvents },
selected = scope == RecurringWriteScope.AllEvents,
)
}
} else {
Text(stringResource(R.string.event_delete_body))
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
) {
Text(
text = stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
private fun shareFileName(title: String): String {
val base = title.trim()
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
.replace(' ', '_')
.take(40)
.ifBlank { "event" }
return "$base.ics"
}
@@ -1,137 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.atOccurrence
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import kotlin.coroutines.cancellation.CancellationException
import javax.inject.Inject
/** A delete waiting on its confirmation dialog. */
data class DeletePrompt(val event: EventInstance, val isRecurring: Boolean)
/** How an action on a selected event ended, when it has something to say. */
enum class EventActionFailure { LoadFailed, ShareFailed, DeleteFailed, WriteDenied, Gone }
/** Duplicate, share and delete for a selected event (#141), loading it on demand. */
@HiltViewModel
class EventActionsViewModel @Inject constructor(
private val repository: CalendarRepository,
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
/** Every calendar by id, for what a selected event's bar may offer. */
val calendars: StateFlow<Map<Long, CalendarSource>> = repository.calendars()
.map { list -> list.associateBy { it.id } }
.catch { emit(emptyMap()) }
.flowOn(io)
.stateWhileSubscribed(
scope = viewModelScope,
initialValue = emptyMap(),
)
private val _deletePrompt = MutableStateFlow<DeletePrompt?>(null)
val deletePrompt: StateFlow<DeletePrompt?> = _deletePrompt.asStateFlow()
private val _failure = MutableStateFlow<EventActionFailure?>(null)
val failure: StateFlow<EventActionFailure?> = _failure.asStateFlow()
/** A create form copying [event], or null when it can't be read. */
suspend fun duplicateForm(event: EventInstance): EventForm? {
val detail = load(event) ?: return null
val canModify = calendars.value[event.calendarId]?.canModifyContents == true
return detail.toDuplicateForm(canModify, TimeZone.currentSystemDefault())
}
/** A staged `.ics` for [event], or null — reported — when it can't be built. */
suspend fun shareUri(event: EventInstance): Uri? {
val uri = load(event)?.let { withContext(io) { icsExporter.stageEventShare(it) } }
if (uri == null && _failure.value == null) _failure.value = EventActionFailure.ShareFailed
return uri
}
/** Ask to delete [event]; the dialog it needs depends on whether it recurs. */
fun requestDelete(event: EventInstance) {
viewModelScope.launch {
val detail = load(event) ?: return@launch
_deletePrompt.value = DeletePrompt(event, isRecurring = !detail.rrule.isNullOrBlank())
}
}
fun confirmDelete(scope: RecurringWriteScope) {
val prompt = _deletePrompt.value ?: return
_deletePrompt.value = null
viewModelScope.launch {
val event = prompt.event
_failure.value = when (repository.tryDeleteEvent(event.eventId, event.start.toEpochMilliseconds(), scope)) {
DeleteOutcome.Deleted -> null
DeleteOutcome.Gone -> EventActionFailure.Gone
DeleteOutcome.NeedsPermission -> EventActionFailure.WriteDenied
DeleteOutcome.Failed -> EventActionFailure.DeleteFailed
}
}
}
fun cancelDelete() {
_deletePrompt.value = null
}
/** The chooser itself couldn't open — nothing on the device takes an `.ics`. */
fun reportShareFailed() {
_failure.value = EventActionFailure.ShareFailed
}
fun consumeFailure() {
_failure.value = null
}
/** Whether [calendarId] takes writes, asking the provider while [calendars] hasn't loaded yet. */
suspend fun canModify(calendarId: Long): Boolean {
val calendar = calendars.value[calendarId] ?: try {
repository.calendars().first().firstOrNull { it.id == calendarId }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
null
}
return calendar?.canModifyContents == true
}
private suspend fun load(event: EventInstance): EventDetail? = try {
// The row holds the series start; the actions work on this occurrence.
repository.eventDetail(event.eventId)
.atOccurrence(event.start.toEpochMilliseconds(), event.end.toEpochMilliseconds())
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
_failure.value = EventActionFailure.Gone
null
} catch (e: Exception) {
_failure.value = EventActionFailure.LoadFailed
null
}
}

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