Compare commits
238 Commits
v2.13.1
...
761b299e6a
| Author | SHA1 | Date | |
|---|---|---|---|
| 761b299e6a | |||
| 4bdcf20c82 | |||
| 9bcd08a5bb | |||
|
|
ca2069da0d | ||
|
|
02cf32b537 | ||
|
|
e44d70e18b | ||
|
|
401e067945 | ||
|
|
a0827202b3 | ||
|
|
36bbb3ff2e | ||
|
|
1d07b64a28 | ||
| d037492cf6 | |||
|
|
db7094c54e | ||
| c70412c782 | |||
| 8e2109d073 | |||
| ac0c43f930 | |||
| 314236ac0c | |||
| c6e83fc071 | |||
| bf6415c023 | |||
| 7aef01d95e | |||
| ce4d6bc4d1 | |||
| 4ad805e747 | |||
| bb6e3ad336 | |||
| edbeadfa30 | |||
| ef48717e2c | |||
| 41593a0e9d | |||
| 4c1bfc052e | |||
| bba536394f | |||
| bb34973047 | |||
| 108a1890be | |||
| 521c0ffd78 | |||
| 33a37d4105 | |||
| 6ccc0cbed2 | |||
|
|
6e6ffce271 | ||
|
|
23386bb998 | ||
|
|
28b1568486 | ||
|
|
5127971a37 | ||
|
|
0562af8d66 | ||
|
|
e3d3728bbd | ||
|
|
8cc9d075fd | ||
|
|
ede6d967c1 | ||
| c30f6ab318 | |||
| dd2b96b5fd | |||
| c22658db4c | |||
| 7b3893ddc6 | |||
| 9e28fa4981 | |||
| 7549c8fe04 | |||
| 75f699b714 | |||
| 3e821c9092 | |||
| 7602f158a3 | |||
| 052335e842 | |||
| 28fb7404d8 | |||
| 6ab235e1d8 | |||
| fb90c94e37 | |||
| 7a05fc3ac1 | |||
| bf38dac5d7 | |||
| 1e9581528e | |||
| 279872aa3b | |||
| ff6c4b6a4d | |||
| 7fb01ab8d1 | |||
| bdb16d069e | |||
| beb8536e8a | |||
| 63ba69729e | |||
| 01a6c8bab8 | |||
| f70b33b864 | |||
| 0df420e551 | |||
| 488de490f9 | |||
| f15fffa799 | |||
| 1499b5803d | |||
| 39cc70be95 | |||
| f9619e74d9 | |||
| e691336b5e | |||
| 4cee160c12 | |||
| a7ae8ff912 | |||
| d23a1d5b6d | |||
| b417900ddd | |||
| 95f4d28bbb | |||
| 24b005126e | |||
| ee9d65068d | |||
| 6e3d10858f | |||
| 94a82c2f6c | |||
| 81dcbbdce7 | |||
| becb9a6710 | |||
| 542744e342 | |||
| 7510e1f9af | |||
| 498250650c | |||
| ada6976e85 | |||
| 6a454e0b33 | |||
| cd6ae31d0f | |||
| 463811056d | |||
| 98d76deee5 | |||
| 20e18768eb | |||
| a34f29bcf8 | |||
| 997296e21e | |||
| 94355bf340 | |||
| 722fc87259 | |||
| 30fcbfa59f | |||
| c956ac79ed | |||
| b90f07a816 | |||
| 7d357bad87 | |||
| 3ffe76412e | |||
| 91c4a84818 | |||
| ccd433fee1 | |||
| 03f6b7c8c1 | |||
| 426ddf27ee | |||
| 4148196a36 | |||
| 9a1d753abc | |||
| 1b29abed17 | |||
| 85d72ad051 | |||
| fd3bcdf2f7 | |||
| 46afa830b3 | |||
| 7de9b2f81b | |||
| 3ec46c8631 | |||
| 529dc9c374 | |||
| 60ef920b82 | |||
| 7b7b859fee | |||
| 53d42341c0 | |||
| b5c2930609 | |||
| 3feafe38f2 | |||
| 55ffaad94f | |||
|
|
5bdde146c1 | ||
|
|
97f87fb927 | ||
|
|
469166c0bb | ||
|
|
b10965babe | ||
|
|
e5c122a2ee | ||
|
|
3aefc5c8f8 | ||
|
|
ecc643ac81 | ||
| 8e4d0defa2 | |||
| 19baed9291 | |||
| 4ea68adf8b | |||
| f459f7d39c | |||
| c3ba8ccf64 | |||
| 7ed1d89b66 | |||
| 734bcae02d | |||
| 629896b74d | |||
| 6a49539ae5 | |||
| 01ac61185b | |||
| ca01f6e729 | |||
| 0588609c75 | |||
| ab3df639b9 | |||
| fefe1f3652 | |||
| 7634df3cff | |||
| 60938af9f0 | |||
| accf0ac142 | |||
| f90badfcd5 | |||
| 249606a358 | |||
| 5be5ff77d0 | |||
| 6519cbca79 | |||
| 50510a23da | |||
| 2279738371 | |||
| 3d59962112 | |||
| a96d8b6de9 | |||
| 94b9eeaa78 | |||
|
|
85fb091e22 | ||
| 2139f8729c | |||
| 98a48aa795 | |||
| d255d232b9 | |||
| 9999fcdd2a | |||
| 953ffdff97 | |||
| 5377a2b466 | |||
| e73148dc6c | |||
| a19772e3a7 | |||
| b3386eff43 | |||
| 79e74d9995 | |||
| 396a5610aa | |||
| b9329f6fb6 | |||
| 1b731a4ab0 | |||
| ab631365b2 | |||
| 9aa370d583 | |||
|
|
8cd75716ae | ||
|
|
4feccf2008 | ||
| 23e9a5a35b | |||
| 38a35be0f0 | |||
| f9f0572ec5 | |||
| 0221972e6d | |||
| 86ea72d56f | |||
| 114db7939c | |||
| 4503847c0d | |||
| 95c5fe76e6 | |||
| 34fa9c9c3e | |||
| 53793bfb68 | |||
| 9c7c8cb03a | |||
| 4fea176e28 | |||
| cf9492c7ba | |||
| e6def9e5f7 | |||
| 974185f354 | |||
| 6aacdd9111 | |||
| 4f263d00fe | |||
| bda002684c | |||
| b6bcd195b0 | |||
| 9fb4502ed0 | |||
| d398c72005 | |||
| 9d718e0f51 | |||
| 8ddc11159b | |||
| 2218c11d3f | |||
|
|
6736bae5ee | ||
| 60aa889c57 | |||
| 3c9767387b | |||
|
|
21d1726d7a | ||
| 1e7ee5b98a | |||
| a82df3f6d0 | |||
| e0e3eb73b9 | |||
| 8a7a0af207 | |||
| 71a652ce3b | |||
| 5fa6eac1ad | |||
| 21ba3fb7cf | |||
| 316c2b0b12 | |||
| ef77eab627 | |||
| 955d47ef12 | |||
| 8536774522 | |||
| a8aeae5f32 | |||
| 988ac009b5 | |||
| 494e486998 | |||
| 3c73028c80 | |||
| 2be1be19fe | |||
| 1f050c2be9 | |||
|
|
5771b603f2 | ||
|
|
5725989aff | ||
| 8b22e1b2af | |||
| a514b8b506 | |||
| 60fcc6b64c | |||
| d6bc660983 | |||
| b449fff77c | |||
| f0cc35f2ce | |||
| 2ce79942c4 | |||
| df426bb8df | |||
| 31f51554f9 | |||
| e967007bdc | |||
| 9f7427e72f | |||
| 79d9e0eaa0 | |||
| a98dd654a6 | |||
| a138e179dd | |||
| 94e3887345 | |||
| f440d385fa | |||
| 8fb4767888 | |||
| 993d74502f | |||
| 4a11c951ae | |||
| f25f308326 | |||
| bff683a403 |
@@ -29,6 +29,7 @@ jobs:
|
||||
with:
|
||||
# Full history so the base..HEAD diff below has a merge-base.
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
# Cheap, always-on guard: the release build must stay reproducible for the
|
||||
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
|
||||
@@ -71,9 +72,14 @@ jobs:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
|
||||
# data.forgejo.org, Forgejo's own action mirror — actions/checkout,
|
||||
# setup-java and cache all exist there, but android-actions/setup-android
|
||||
# does not, and the job dies with "repository not found". Gitea's instance
|
||||
# defaults to GitHub, which is why this never surfaced before the split.
|
||||
- name: Setup Android SDK
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
uses: android-actions/setup-android@v3
|
||||
uses: https://github.com/android-actions/setup-android@v3
|
||||
with:
|
||||
# Default ("tools platform-tools") drags in the Android Emulator
|
||||
# (~300 MB) which the build never uses.
|
||||
@@ -3,13 +3,15 @@ name: Translations
|
||||
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
||||
# only touch values-*/strings.xml) get quick feedback without the full Android
|
||||
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
||||
#
|
||||
# Runs on every PR (no path filter) so the required "Translations / check"
|
||||
# status is always reported — like the `ci` job. A path-filtered workflow is
|
||||
# skipped on unrelated PRs and never posts its status, which leaves that
|
||||
# required check pending forever and blocks the merge of any code-only PR into a
|
||||
# release/* branch. The check itself is cheap and simply passes when the
|
||||
# committed translations are consistent, so always running it costs nothing.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'app/src/main/res/values*/strings.xml'
|
||||
- 'app/src/main/res/xml/locales_config.xml'
|
||||
- 'scripts/check_translations.py'
|
||||
- '.gitea/workflows/translations.yaml'
|
||||
|
||||
concurrency:
|
||||
group: translations-${{ github.ref }}
|
||||
@@ -1,11 +1,13 @@
|
||||
name: Release — F-Droid repo + Gitea release
|
||||
name: Release — F-Droid repo + Gitea/Codeberg release
|
||||
|
||||
# A release is cut by merging a release branch into main with a bumped
|
||||
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
|
||||
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
|
||||
# it to the F-Droid repo, and only then creates the vX.Y.Z tag + Gitea release
|
||||
# itself — the tag is an output of the pipeline, not its trigger. Ordinary
|
||||
# merges (no version bump) fall through `detect` and do nothing.
|
||||
# it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and mirrors
|
||||
# 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.
|
||||
#
|
||||
# A manual workflow_dispatch (from a branch) runs the re-sign-only recovery
|
||||
# path: it re-signs the existing F-Droid index with the repo key and re-uploads,
|
||||
@@ -25,6 +27,14 @@ jobs:
|
||||
# whether this push actually cuts a new release (no tag for it yet). Keeps the
|
||||
# heavy job from running on every merge to main.
|
||||
detect:
|
||||
# Gitea only. The workflow directory split already keeps this file invisible
|
||||
# to Codeberg — Forgejo's lookup is first-match-wins, and .forgejo/workflows
|
||||
# exists — but that only holds while .forgejo/ is non-empty. Move the last
|
||||
# file out of it and Codeberg would fall back to .gitea/workflows and start
|
||||
# running the release pipeline on the contributor-facing runner, with no
|
||||
# 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: docker
|
||||
outputs:
|
||||
is_release: ${{ steps.v.outputs.is_release }}
|
||||
@@ -33,12 +43,22 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Resolve version and whether it is a new release
|
||||
id: v
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
# 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
|
||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||
@@ -56,15 +76,28 @@ jobs:
|
||||
fi
|
||||
# A tag for this version already existing means the release shipped on
|
||||
# an earlier push; do nothing. Absent => this merge cuts the release.
|
||||
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "Tag v$VERSION already exists — nothing to release."
|
||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No tag for v$VERSION yet — cutting the release."
|
||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
#
|
||||
# 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
|
||||
@@ -82,6 +115,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
@@ -346,3 +381,99 @@ jobs:
|
||||
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 +
|
||||
# tags to Codeberg, but releases aren't git objects so they don't sync —
|
||||
# we create the release there over the API and attach the signed APK plus
|
||||
# a SHA-256 checksum. The APK is identical to the F-Droid one (same app
|
||||
# key), so this adds no trust surface. Best-effort: a Codeberg outage
|
||||
# (it 504s under load) must never fail an already-published F-Droid
|
||||
# release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
|
||||
- name: Publish release to Codeberg
|
||||
if: env.IS_RELEASE == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
|
||||
API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
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."
|
||||
|
||||
@@ -29,14 +29,30 @@ jobs:
|
||||
- name: Run Renovate
|
||||
run: renovate
|
||||
env:
|
||||
# Self-hosted Gitea, not github.com.
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_ENDPOINT: https://gitea.jeanlucmakiola.de/api/v1
|
||||
# Bot-account token (Gitea secret). Needs repo read/write + PR scope.
|
||||
# Renovate targets Codeberg (canonical) while still RUNNING on the
|
||||
# Gitea runner. Moving the job to Codeberg would put a repo-write
|
||||
# token on the contributor-facing runner, which is exactly what the
|
||||
# .forgejo/ vs .gitea/ split exists to prevent — so the token stays
|
||||
# where the other secrets live and only the API calls cross over.
|
||||
#
|
||||
# Platform is `forgejo`, not `gitea`: Codeberg runs Forgejo, and the
|
||||
# pinned image ships a distinct forgejo platform module.
|
||||
RENOVATE_PLATFORM: forgejo
|
||||
RENOVATE_ENDPOINT: https://codeberg.org/api/v1
|
||||
# Codeberg bot-account token (Gitea secret). Needs repo read/write +
|
||||
# PR scope on jlmakiola/calendula.
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
# Scope to this repo only — no org-wide autodiscovery.
|
||||
RENOVATE_AUTODISCOVER: 'false'
|
||||
RENOVATE_REPOSITORIES: '["makiolaj/calendula"]'
|
||||
# Commits/PRs authored as the bot, not a real maintainer.
|
||||
RENOVATE_REPOSITORIES: '["jlmakiola/calendula"]'
|
||||
# Commits/PRs authored as the bot, not a real maintainer. This address
|
||||
# must be a verified email on the Codeberg bot account, otherwise the
|
||||
# commits show up unattributed there.
|
||||
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@jeanlucmakiola.de>'
|
||||
# Read-only github.com PAT (no scopes needed). Unaffected by the forge
|
||||
# move — nearly every dependency is *released* on GitHub, and without
|
||||
# this,
|
||||
# changelog/release-note lookups hit the 60/h anonymous rate limit
|
||||
# and PRs arrive with an empty "Release Notes" section.
|
||||
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.GITHUB_COM_TOKEN }}
|
||||
LOG_LEVEL: info
|
||||
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "floret-kit"]
|
||||
path = floret-kit
|
||||
url = https://codeberg.org/jlmakiola/floret-kit.git
|
||||
413
CHANGELOG.md
413
CHANGELOG.md
@@ -5,6 +5,383 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Settings → Calendars now says what is different about a calendar instead of
|
||||
leaving you to guess. Ones you can only view — a subscribed calendar, a
|
||||
calendar shared with you read-only — are marked **Read-only** ([#76]).
|
||||
- Calendars your device isn't syncing are marked **Not synced**, moved to the
|
||||
bottom of their account and left without a switch. None of their events are on
|
||||
the device, so the switch they used to have could not have shown you anything
|
||||
— the calendar simply looked broken. They are no longer offered when you pick
|
||||
a calendar for a new or an imported event either: an event saved there would
|
||||
never reach the account. Whether an account syncs a calendar stays that
|
||||
account's own app's decision ([#78]).
|
||||
- The birthday and anniversary calendars Calendula fills from your contacts are
|
||||
marked **Filled from your contacts**, which is why they can't be picked for a
|
||||
new event: anything you put there would be removed again on the next sync.
|
||||
Deleting one is held back while special dates are switched on — Calendula
|
||||
would simply create it again — and the calendar's editor says so; turn the
|
||||
feature off under Settings → Special dates and the delete works as usual
|
||||
([#76]).
|
||||
- The calendar picker in the event form and in the .ics import screen now ends
|
||||
with a **"Missing a calendar?"** row that opens Settings → Calendars, where
|
||||
those marks then explain why a calendar isn't offered ([#76]).
|
||||
- The agenda widget's text size is yours to set. **Settings → Widgets & tiles →
|
||||
Agenda widget size** offers Small, Medium, Large and Extra large, replacing the guess
|
||||
the widget used to make from its own measurements. Small is what it looks like
|
||||
today, so nothing changes until you turn it up ([#51]).
|
||||
- A repeating event now shows you its next few dates, not just a description of
|
||||
the rule. Both the preset list and the custom recurrence picker carry a
|
||||
**Next:** line — "Next: 30 Jul, 6 Aug, 13 Aug" — under the rule they would
|
||||
save. A phrase like "monthly" on the 31st, or "every 2 weeks on Mon & Fri",
|
||||
can mean something other than it sounds like, and only the dates say so. A
|
||||
rule that can never fire says that instead ([#69]).
|
||||
- The event visibility options now say who they affect. **Public**, **Private**
|
||||
and **Confidential** each carry a line about what other people on a shared
|
||||
calendar see — the part the four words on their own leave out ([#69]).
|
||||
|
||||
### Changed
|
||||
- **Settings has been reorganised so each setting sits where you would look for
|
||||
it.** One long undifferentiated list is now three labelled groups — Look &
|
||||
behaviour, Data, and App — whose rows open sub-screens and say what they do
|
||||
rather than only naming themselves. Appearance, Views, New event form,
|
||||
Notifications and the new Widgets & tiles are separate screens now, so the
|
||||
settings for a calendar view are no longer mixed in with the ones for the
|
||||
app's colours or for the home-screen widgets ([#69]).
|
||||
- Settings that are hard to picture from their name now show you what they do.
|
||||
The week-start picker rearranges a real month grid as you choose, the
|
||||
past-events setting previews a sample agenda day for Show, Dim and Hide, the
|
||||
font pickers set a specimen line in the face you are choosing, and the Agenda
|
||||
range options carry the dates each one actually covers. Options that follow
|
||||
the system additionally name which way they currently fall ([#69]).
|
||||
- **Backup & restore** is now its own Settings entry instead of living inside
|
||||
the calendar manager, where it was easy to miss — keeping a copy of your
|
||||
calendars is a different question from which calendars you have. The calendar
|
||||
manager keeps a row pointing to it, and nothing about how backup or automatic
|
||||
backup works has changed ([#69]).
|
||||
- Calendula's source code now lives on **Codeberg**, where its issues already
|
||||
were. The **Source code** and **License** links in Settings → About point
|
||||
there, so reporting a bug and reading the code no longer land on two different
|
||||
sites. Nothing about the app itself changes, and the F-Droid repository is
|
||||
unaffected.
|
||||
|
||||
### Fixed
|
||||
- A month-grid widget stays a month grid, and draws all seven days again. Since
|
||||
2.16.0 a placed month widget could redraw itself as the agenda widget a little
|
||||
after any change to your events, and could draw only about four day columns
|
||||
with the last one cut off part-way through. Both came from the release build
|
||||
merging the two widgets into a single class, so Android could no longer tell
|
||||
which of them a widget on your home screen was — and the month grid was handed
|
||||
the wrong widget's measurements to lay its columns out against ([#89], [#103]).
|
||||
- The back gesture on **Settings → Views** returns to Settings instead of
|
||||
leaving Settings altogether and dropping you on the calendar. Special dates
|
||||
did the same ([#81]).
|
||||
- The dots standing in for the events that didn't fit a day in the month view
|
||||
now dim with everything else when **Dim completed events** is on. A past day
|
||||
with four or more events kept its last events at full strength while the rest
|
||||
faded ([#79]).
|
||||
- Two accounts that happen to share a name — a Google account and a DAVx5
|
||||
account for the same address, say — are no longer merged into one group.
|
||||
They were listed together in Settings → Calendars and in the drawer's filter,
|
||||
which also meant the group's source icon and its "manage in app" button could
|
||||
send you to the wrong app, "toggle all" spanned both accounts at once, and
|
||||
collapsing one collapsed the other. Where a name really is shared, each group
|
||||
now names the app it comes from ([#77]).
|
||||
- In the month view's **Split** style, the new-event button now starts on the
|
||||
day you have selected. It always started on today, whichever day was selected
|
||||
and listed below the grid ([#87]).
|
||||
- Search results now show an all-day event's real date. West of UTC — anywhere in
|
||||
the Americas, say — a search hit was dated one day early, disagreeing with the
|
||||
day the month, week and agenda views file the same event under ([#82]).
|
||||
- Reminders no longer depend on Android telling Calendula when they are due.
|
||||
Calendula now works out each reminder's time itself and sets its own alarm for
|
||||
it. On some phones — Samsung's among them — the system's calendar storage never
|
||||
sends the signal a calendar app is meant to wake up on, and no amount of
|
||||
battery or notification settings helps: the reminder is simply never announced.
|
||||
None of that is visible from inside an app that waits to be told, which is why
|
||||
it took a second pass to find ([#75]).
|
||||
|
||||
Reminders also survive things that used to lose them quietly. After a restart
|
||||
or an app update Calendula re-arms its alarms, and a reminder whose moment
|
||||
passed while the phone was off still arrives, as long as the event has not
|
||||
ended yet.
|
||||
|
||||
- All-day reminders now arrive at the time you chose in **Settings →
|
||||
Notifications**, on every occurrence. A yearly birthday could drift an hour
|
||||
either way depending on daylight saving, and all-day reminders on calendars
|
||||
from an account fired in the middle of the night instead of in the morning
|
||||
([#75]).
|
||||
|
||||
- Reminders now arrive for every calendar you have switched on. A calendar that
|
||||
was hidden at system level — switched off in another calendar app, or never
|
||||
switched on after being added — still showed its events and listed their
|
||||
reminders in Calendula, but never notified: Android only schedules reminder
|
||||
alarms for calendars marked visible, and Calendula kept its own separate
|
||||
on/off list that had no say in it. There is now one switch: **Settings →
|
||||
Calendars** turns a calendar on or off for the whole device, so what you see
|
||||
and what reminds you can no longer disagree ([#75]).
|
||||
|
||||
Calendars you had switched off in Calendula are switched off here too on first
|
||||
launch. Calendars that were already off — hidden in another calendar app, or
|
||||
never switched on after being added — stay off, and Calendula says so once
|
||||
rather than quietly switching them on for every app on your device; you can
|
||||
turn any of them back on in Settings → Calendars.
|
||||
|
||||
If you gave Calendula read-only access to your calendars, the switch still
|
||||
works: your choice is kept in the app until it can be written.
|
||||
|
||||
The drawer's filter is unchanged and still app-only: hiding a calendar there
|
||||
tidies your view without silencing its reminders.
|
||||
|
||||
## [2.16.0] — 2026-07-24
|
||||
|
||||
### Added
|
||||
- Choose how the month view is laid out. A new **Month view style** setting
|
||||
(Settings → Views) offers three ways to read a month, each shown with a
|
||||
preview of the layout it produces:
|
||||
- **Pages** — what you have today: one month at a time, swiped sideways.
|
||||
- **Continuous** — scroll up and down through the weeks without a break
|
||||
between months. Because the weeks run on unbroken, no month is cut off and
|
||||
no day appears twice, where paging repeats a boundary week at the end of one
|
||||
month and the start of the next. The 1st of each month names itself so you
|
||||
always know where you are, and the title bar keeps up as you scroll ([#38]).
|
||||
- **Split** — a compact grid showing coloured dots for the days that have
|
||||
something on them, with the day you tap listed in full underneath. Tap the
|
||||
date above the list to open the whole day ([#53]).
|
||||
|
||||
The Agenda view is untouched by this and stays available in all three styles —
|
||||
the split layout lists a single day, while Agenda remains a rolling multi-day
|
||||
window with its own range settings.
|
||||
- Give an event its own time zone. A new **Time zone** field (under "more
|
||||
fields" in the event form) pins an event to a specific zone, so a call set for
|
||||
8:00 AM in New York stays 8:00 AM in New York wherever you open it — and keeps
|
||||
tracking that zone across daylight-saving changes instead of drifting an hour.
|
||||
The form edits the event in its own zone and shows the local equivalent under
|
||||
the times ("2:00 PM – 3:00 PM your time"); the event's details keep your local
|
||||
time first and note the original beneath it, so both are always clear. Pick a
|
||||
zone from a full-screen picker with your device zone and recent choices on top,
|
||||
searching by city ("new york"), IANA id ("europe/berlin"), or abbreviation
|
||||
("CEST") to gather every matching zone at once. All-day events stay
|
||||
date-anchored and carry no zone, as before ([#31]).
|
||||
- Put the "jump to today" button in the toolbar. A new **Today button in
|
||||
toolbar** setting (Settings → Appearance, off by default) swaps the floating
|
||||
button that fades into the corner while you're away from today for a permanent
|
||||
today icon in the top bar — always there, on today or not, matching the
|
||||
familiar calendar-app pattern. Leave it off to keep the floating button as
|
||||
before ([#60]).
|
||||
- Choose what Calendula calls itself on your home screen. A new **App name**
|
||||
setting (Settings → Appearance) switches the launcher label between
|
||||
**Calendula** and **Calendar**, for launchers that can't rename apps
|
||||
themselves. Pick from a full-screen chooser that previews both names as
|
||||
launcher marks; the change applies at once ([#44]).
|
||||
- Calendula now speaks **Arabic**, laid out right-to-left, and its French and
|
||||
Italian translations have been brought up to date — thanks to the community
|
||||
translators on Weblate. Pick a language under Settings → Language (or leave it
|
||||
on the system default).
|
||||
|
||||
### Changed
|
||||
- Dates in the Month, Week and Day title bars now follow your language and
|
||||
region instead of one hardcoded layout. Every date was rendered in a fixed
|
||||
German-style order with a trailing dot on the day number, whatever your
|
||||
settings: US English showed "Fri, 17. Jul 2026" where it should read
|
||||
"Fri, Jul 17". The Agenda view already formatted correctly, so the two
|
||||
disagreed about the same date. All four views now share one formatter, and the
|
||||
day/month order, the separators and the ordinal all come from your locale —
|
||||
so English-in-Germany reads "Fri, 17 Jul" and English-in-the-US "Fri, Jul 17",
|
||||
each correct for where you are ([#60]).
|
||||
- The title bar drops the year while you're in the current one — "July" rather
|
||||
than "July 2026". The year reappears the moment you page out of the current
|
||||
year, which is when it tells you something you didn't already know.
|
||||
- The Week view's title now names the month instead of spelling out the day range.
|
||||
"24. Jun – 31. Jun" restated the day numbers already printed in the column
|
||||
headers right below it, in the widest string in the bar. A week that straddles
|
||||
two months keeps the outgoing month until it is fully gone ([#60]).
|
||||
- The custom recurrence picker has been redesigned and tightened up. As you
|
||||
build a rule — "every 2 weeks on Mon & Wed, until a date" — the live summary
|
||||
now describes exactly what will be saved rather than a near-copy that could
|
||||
drift from it, the amount fields accept being left blank (reading as their
|
||||
shown default instead of greying out OK), and the read-out no longer jumps
|
||||
around as you tap weekdays ([#42]).
|
||||
|
||||
### Fixed
|
||||
- An all-day event no longer shows up again the day after it happened. In time
|
||||
zones east of UTC, an all-day event — a birthday, say — set for one day also
|
||||
appeared under the *next* day's heading in the Agenda (and the agenda widget),
|
||||
because all-day events are anchored to UTC midnight and the following day's
|
||||
window reached back across that boundary and pulled the event forward onto
|
||||
"today". Each all-day event now lists only on the day it actually falls on
|
||||
([#65]).
|
||||
- A multi-day event now shows under every day it spans in the Agenda, not just
|
||||
its first day, so a trip or a multi-day booking appears on each day it covers.
|
||||
- The "Upcoming" agenda widget now scales its text and rows to the size you give
|
||||
it. Previously it was laid out once for the smallest size and simply stretched
|
||||
when enlarged, so the text stayed small no matter how big you made the widget.
|
||||
Now a bigger widget gets bigger, more readable type and roomier rows, while the
|
||||
default size looks exactly as before — no new setting; it follows the size you
|
||||
already chose ([#51]).
|
||||
- Calendula now appears under other apps' "Add to calendar" / "Save to calendar"
|
||||
actions. Some apps (e.g. DB Navigator) fire the widely-used "insert event"
|
||||
intent with the singular `vnd.android.cursor.item/event` type, which Calendula
|
||||
didn't advertise — so it was left out of the chooser, and if it was your only
|
||||
calendar app the save silently did nothing. It now accepts that form, plus the
|
||||
`INSERT_OR_EDIT` action, and opens the new event prefilled for review ([#74]).
|
||||
- Opening a `.ics`/`.vcs` file now works even when another app hands it over
|
||||
mislabelled as a generic download (`application/octet-stream`), as some mail
|
||||
clients, browsers and file managers do — Calendula recognises it by its file
|
||||
extension instead of relying on the declared type ([#74]).
|
||||
- A recurrence end date no longer lands a day late. West of UTC, setting a rule
|
||||
to end "until" a given day could save and show the day after the one picked;
|
||||
the end date now reads back as chosen ([#42]).
|
||||
- The status- and navigation-bar icons stay legible over full-screen pickers in
|
||||
dark theme. They could render dark-on-dark — a near-invisible black clock
|
||||
against the dark picker — instead of switching to light ([#70]).
|
||||
|
||||
## [2.15.0] — 2026-07-15
|
||||
|
||||
### Added
|
||||
- Show raw calendar colours. Calendula normally softens each calendar and event
|
||||
colour toward a theme-fitting pastel so harsh sync colours read well on both
|
||||
light and dark; a new **Soften calendar colours** setting (Settings → Design,
|
||||
on by default) lets you turn that off and paint the exact colours your calendar
|
||||
source publishes — matching DAVx5/CalDAV and other calendar apps. Thanks to
|
||||
@leonp5 for the report ([#36]).
|
||||
- Readable titles on dark event colours. An event bar's title now shows in white
|
||||
on a dark colour and near-black on a light one, chosen automatically from the
|
||||
colour's brightness, so a deep blue or purple event is legible at a glance in
|
||||
the busy Week and Month views instead of dark-on-dark. This applies whether or
|
||||
not colours are softened. Thanks to @ptab for the suggestion ([#21]).
|
||||
- A custom snooze duration. The **Snooze duration** setting (Settings →
|
||||
Notifications) gains a **Custom…** option next to the minute presets: pick any
|
||||
amount and switch between minutes and hours, so a snoozed reminder comes back
|
||||
after exactly the delay you want instead of only a preset one ([#40]).
|
||||
- Move an event to another calendar. When editing an existing event, the
|
||||
calendar row is now tappable — pick a different calendar and saving moves the
|
||||
event across, instead of having to delete it and recreate it elsewhere.
|
||||
Recurring series move as a whole, keeping their individually-edited and
|
||||
cancelled occurrences, and any reminders and guests come along too. A calendar
|
||||
can't simply be reassigned underneath an event, so Calendula recreates it on
|
||||
the target and removes the original — the same approach other calendar apps
|
||||
take. Thanks to @prismplex for the suggestion ([#39]).
|
||||
- Open an event straight into the edit form from another app. Calendula already
|
||||
answered the "new event" and "open this event" hand-offs from other apps and
|
||||
widgets; it now also answers the "edit this event" one, so an assistant, task
|
||||
app, or widget can send an existing event to Calendula and land on its edit
|
||||
screen rather than the read-only details. A hand-off with no event attached
|
||||
opens the same prefilled create form as "new event". Calendula also recognises
|
||||
a couple more file labels the same calendar data arrives under (`.vcs`
|
||||
vCalendar files and the `application/ics` type), so opening or sharing those
|
||||
into Calendula works too.
|
||||
- Keep today at the top of the agenda. A new **Always show today** setting
|
||||
(Settings → Agenda, on by default) anchors today as the first entry in both the
|
||||
Agenda screen and its home-screen widget even once nothing is left today —
|
||||
under today's header a "No more events today" note appears — so the first
|
||||
events you see are clearly today's rather than a future day's. Turn it off to
|
||||
keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]).
|
||||
- Duplicate an event. The event details now carry a **Duplicate** action that
|
||||
opens the editor pre-filled with a copy of the event as a new, unsaved one, so
|
||||
a one-off like a shift or an appointment can be recreated by just changing the
|
||||
day and time instead of re-typing every field. The copy keeps the original's
|
||||
time, and its title, location, notes, colour, guests and reminders come along;
|
||||
it's saved as its own single event (any repeat is left off — add one in the
|
||||
editor if you want it). Duplicate works from read-only calendars too, dropping
|
||||
the copy into a writable one. Thanks to @internet-rando for the suggestion
|
||||
([#52]).
|
||||
- French and Polish, in early form. Calendula has started speaking French and
|
||||
Polish, both contributed as community translations through
|
||||
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||
They are partway there, so untranslated parts still show in English until they
|
||||
fill out — you can already pick either under Settings → Language or in Android's
|
||||
per-app language settings. Thanks to Thomas Tref (French) and Bazyli Cyran
|
||||
(Polish) for getting them started; help finishing them is very welcome.
|
||||
|
||||
### Fixed
|
||||
- Reminders for events on another day no longer read as if they were today. A
|
||||
reminder fired ahead of time — say, the day before — used to show only the
|
||||
event's time, making it look like it was happening now. The notification now
|
||||
says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
|
||||
week, or the date for anything further out. Thanks to @moonj for the report
|
||||
([#46]).
|
||||
- Agenda dates now read in your locale's format. The agenda's range bar and its
|
||||
day headers used a fixed day-month-year layout — and the range span even mixed
|
||||
two orders (e.g. "15 Jul – Aug 13, 2026") — instead of following your language's
|
||||
conventions. Dates across the agenda and its widget now use your locale's own
|
||||
field order, matching the rest of the app. The range bar also no longer repeats
|
||||
the range's name from the selector button beside it, showing just the dates.
|
||||
- Calendar gutters line up with the menu button. The Month view's week-number
|
||||
column, and the Week and Day views' hour labels, sat a few pixels left of the
|
||||
hamburger menu above them; they now line up with it. In Month view the day
|
||||
cells also sit squarely under their weekday letters.
|
||||
|
||||
## [2.14.1] — 2026-07-13
|
||||
|
||||
### Fixed
|
||||
- Deleting one occurrence of a repeating event no longer breaks the series.
|
||||
Choosing "This event" when deleting an occurrence of a recurring event could
|
||||
wipe out every *other* occurrence while leaving the one you deleted behind as
|
||||
a stale, still-tappable ghost — and deleting it again brought the series back.
|
||||
A single-occurrence delete now removes exactly that occurrence and leaves the
|
||||
rest of the series untouched, and the deleted occurrence disappears from the
|
||||
grid straight away. This holds on every kind of calendar, including the
|
||||
on-device ones Calendula keeps for contact birthdays and anniversaries, where
|
||||
the series is a yearly repeat. Thanks to @moonj for the report ([#47]).
|
||||
- Tapping an event in a third-party widget opens it in Calendula. v2.13.1 taught
|
||||
Calendula to answer the "new event" hand-off from other apps and widgets; now
|
||||
it also answers the "open this event" one, so tapping an existing event in a
|
||||
widget such as Todo Agenda offers Calendula and lands on that event's details.
|
||||
Thanks to @bushrang3r for the report ([#48]).
|
||||
- Events created from other apps get your default reminder. An event handed over
|
||||
by another app or widget — Google Maps' "add to calendar", the Todo Agenda
|
||||
widget's "+" — opened with no reminder at all, ignoring the default set in
|
||||
Settings. It now starts with your default reminder, the same as an event you
|
||||
create in Calendula. An event opened from an `.ics` file is treated differently,
|
||||
because the file has its own say: Calendula keeps whatever reminders it carries
|
||||
(including none at all) and asks you once whether to apply your default instead
|
||||
— it never quietly overrides the file. If you have no default set, it doesn't
|
||||
ask ([#49]).
|
||||
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
|
||||
app (such as DAVx5), the event colour picker showed every colour the account
|
||||
publishes — nearly 150 swatches in alphabetical order, many of them
|
||||
duplicates or near-identical shades. The picker now shows only visually
|
||||
distinct colours, arranged as a rainbow; near-duplicate shades and the
|
||||
washed-out neutrals are folded away so no two swatches look alike. Picked
|
||||
colours still sync exactly as before, and calendars with hand-picked
|
||||
palettes (like Google's) are unaffected. Thanks to @ptab for the report
|
||||
([#22]).
|
||||
|
||||
## [2.14.0] — 2026-07-06
|
||||
|
||||
### Added
|
||||
- Restore events from a backup file. The backup section of Settings can now read
|
||||
events back **in** from an `.ics` file, not just write one out: pick a file,
|
||||
choose which calendar to import into, and Calendula adds the events — skipping
|
||||
any that are already there and telling you how many it skipped. Export gained a
|
||||
per-calendar selector at the same time, so you can back up just the calendars
|
||||
you pick instead of everything at once ([#32]).
|
||||
- Week numbers in Month view. A new **Week numbers** setting (off by default)
|
||||
adds a slim gutter down the left of the Month grid showing the calendar-week
|
||||
number for each row, sized to match the day cells. Handy if you plan or refer
|
||||
to dates by week number ([#25]).
|
||||
- Tap a date header to open that day. In Week and Agenda view, tapping a date
|
||||
header now opens that date in Day view — the same drill-in that Month view and
|
||||
the agenda widget already offered, so every view behaves the same way. It makes
|
||||
jumping to a specific day quicker: switch to Week, swipe to the week you want,
|
||||
then tap the date to open it. Thanks to @ptab for the suggestion ([#37]).
|
||||
- An early Simplified Chinese translation. Calendula has started speaking
|
||||
Simplified Chinese, contributed as a community translation through
|
||||
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||
It is still an early effort, so many parts of the app show in English until it
|
||||
fills out — you can already pick it under Settings → Language or in Android's
|
||||
per-app language settings. Thanks to
|
||||
[zh-cn](https://weblate.dev.jeanlucmakiola.de/user/zh-cn/) for getting it
|
||||
started; help finishing it is very welcome.
|
||||
|
||||
### Changed
|
||||
- Long event titles wrap in the edit screen. When editing an event, a long title
|
||||
now wraps onto multiple lines instead of being clipped to a single line, so you
|
||||
can see and edit the whole thing ([#33]).
|
||||
|
||||
## [2.13.1] — 2026-07-06
|
||||
|
||||
### Added
|
||||
@@ -833,8 +1210,44 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
||||
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
|
||||
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
||||
[#22]: https://codeberg.org/jlmakiola/calendula/issues/22
|
||||
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
|
||||
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25
|
||||
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27
|
||||
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
|
||||
[#21]: https://codeberg.org/jlmakiola/calendula/issues/21
|
||||
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
|
||||
[#31]: https://codeberg.org/jlmakiola/calendula/issues/31
|
||||
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
|
||||
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
||||
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||
[#36]: https://codeberg.org/jlmakiola/calendula/issues/36
|
||||
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
||||
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
|
||||
[#35]: https://codeberg.org/jlmakiola/calendula/issues/35
|
||||
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
|
||||
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
|
||||
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
||||
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
||||
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
||||
[#51]: https://codeberg.org/jlmakiola/calendula/issues/51
|
||||
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
|
||||
[#38]: https://codeberg.org/jlmakiola/calendula/issues/38
|
||||
[#53]: https://codeberg.org/jlmakiola/calendula/issues/53
|
||||
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60
|
||||
[#65]: https://codeberg.org/jlmakiola/calendula/issues/65
|
||||
[#74]: https://codeberg.org/jlmakiola/calendula/issues/74
|
||||
[#42]: https://codeberg.org/jlmakiola/calendula/issues/42
|
||||
[#44]: https://codeberg.org/jlmakiola/calendula/issues/44
|
||||
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70
|
||||
[#75]: https://codeberg.org/jlmakiola/calendula/issues/75
|
||||
[#76]: https://codeberg.org/jlmakiola/calendula/issues/76
|
||||
[#78]: https://codeberg.org/jlmakiola/calendula/issues/78
|
||||
[#77]: https://codeberg.org/jlmakiola/calendula/issues/77
|
||||
[#79]: https://codeberg.org/jlmakiola/calendula/issues/79
|
||||
[#81]: https://codeberg.org/jlmakiola/calendula/issues/81
|
||||
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
||||
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
|
||||
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
|
||||
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
||||
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
|
||||
|
||||
<p>
|
||||
<a href="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions"><img src="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
|
||||
<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>
|
||||
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
|
||||
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
|
||||
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
|
||||
|
||||
@@ -28,8 +28,8 @@ android {
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||
versionCode = 21301
|
||||
versionName = "2.13.1"
|
||||
versionCode = 21600
|
||||
versionName = "2.16.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -113,10 +113,13 @@ 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. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
|
||||
// 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 += "MissingTranslation"
|
||||
informational += listOf("MissingTranslation", "MissingQuantity")
|
||||
}
|
||||
|
||||
testOptions {
|
||||
@@ -161,6 +164,12 @@ dependencies {
|
||||
implementation(libs.androidx.glance.material3)
|
||||
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation("de.jeanlucmakiola.floret:core-time")
|
||||
implementation("de.jeanlucmakiola.floret:core-locale")
|
||||
implementation("de.jeanlucmakiola.floret:core-crash")
|
||||
implementation("de.jeanlucmakiola.floret:core-reminders")
|
||||
implementation("de.jeanlucmakiola.floret:identity")
|
||||
implementation("de.jeanlucmakiola.floret:components")
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
|
||||
12
app/proguard-rules.pro
vendored
12
app/proguard-rules.pro
vendored
@@ -38,3 +38,15 @@
|
||||
# SessionWorker never ran, and widgets were stuck on their loading layout
|
||||
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
|
||||
-keep class * extends androidx.work.InputMerger { <init>(...); }
|
||||
|
||||
# Glance identifies a widget by its GlanceAppWidget subclass's *canonical name*:
|
||||
# GlanceAppWidgetManager persists a providerName -> receivers map under that
|
||||
# string, and `updateAll` looks the widget's app-widget ids up through it. Under
|
||||
# R8 full mode (AGP 9 default) MonthWidget and AgendaWidget — same supertype,
|
||||
# same overrides, no distinguishing members — were horizontally merged into one
|
||||
# class, so both receivers registered under the *same* provider name and
|
||||
# `AgendaWidget().updateAll()` resolved the month widget's id too, redrawing a
|
||||
# placed month widget as the agenda one on the next data change (#89). Keeping
|
||||
# the real names also survives app updates, which would otherwise renumber the
|
||||
# obfuscated name and orphan the stored mapping.
|
||||
-keep class * extends androidx.glance.appwidget.GlanceAppWidget
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -31,7 +32,12 @@ class CalendarRepositorySmokeTest {
|
||||
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||
produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") },
|
||||
)
|
||||
return CalendarRepositoryImpl(dataSource, CalendarPrefs(store), Dispatchers.IO)
|
||||
return CalendarRepositoryImpl(
|
||||
dataSource,
|
||||
CalendarPrefs(store),
|
||||
SettingsPrefs(store),
|
||||
Dispatchers.IO,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -33,6 +33,14 @@
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
|
||||
<!--
|
||||
A reboot clears every pending alarm, including the one holding the next
|
||||
reminder. Now that the app schedules that alarm itself (#75) rather than
|
||||
leaning on the provider's, it has to hear about the reboot to re-arm it —
|
||||
otherwise reminders simply stop after a restart.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||
returns null and the calendar manager's per-account "manage" button can't
|
||||
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
||||
@@ -63,10 +71,9 @@
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- The MAIN/LAUNCHER entry (the launcher icon + its label) lives on
|
||||
the two <activity-alias> below, so the app name can be switched at
|
||||
runtime (issue #44). MainActivity keeps every other filter. -->
|
||||
|
||||
<!-- Be selectable as the system calendar app. Android has no API for
|
||||
an app to make itself the default, so registering the filters
|
||||
@@ -97,37 +104,156 @@
|
||||
<data android:mimeType="time/epoch" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||
<!-- Open a .ics/.vcs file (file manager / email attachment / browser).
|
||||
The three MIME types cover the common labels the same calendar
|
||||
data arrives under: iCalendar 2.0 (text/calendar), the older
|
||||
vCalendar 1.0 / .vcs (text/x-vcalendar), and application/ics some
|
||||
mail apps emit — Android cross-products the scheme and mimeType
|
||||
tags, so each MIME is accepted on both schemes (matches Etar). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="content" android:mimeType="text/calendar" />
|
||||
<data android:scheme="file" android:mimeType="text/calendar" />
|
||||
<data android:scheme="content" />
|
||||
<data android:scheme="file" />
|
||||
<data android:mimeType="text/calendar" />
|
||||
<data android:mimeType="text/x-vcalendar" />
|
||||
<data android:mimeType="application/ics" />
|
||||
</intent-filter>
|
||||
<!-- Receive a .ics shared from another app. -->
|
||||
<!-- Same .ics/.vcs data arriving mislabelled as a generic download —
|
||||
application/octet-stream — the way many mail clients, browsers and
|
||||
file managers hand off attachments. Matched by file extension, so
|
||||
this stays a separate filter: a pathPattern here must not narrow
|
||||
the MIME-typed VIEW filter above (that one has no path and must
|
||||
keep matching regardless of name). The import handler ignores the
|
||||
MIME type, so a let-through octet-stream .ics imports normally.
|
||||
Best-effort: pathPattern is reliable for file:// (and content://
|
||||
whose path carries the filename); content:// URIs that expose no
|
||||
name still fall back to the MIME-typed filter above. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="content" />
|
||||
<data android:scheme="file" />
|
||||
<data android:host="*" />
|
||||
<data android:mimeType="application/octet-stream" />
|
||||
<data android:pathPattern=".*\\.ics" />
|
||||
<data android:pathPattern=".*\\.vcs" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Receive a .ics/.vcs shared from another app (same MIME set). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/calendar" />
|
||||
<data android:mimeType="text/x-vcalendar" />
|
||||
<data android:mimeType="application/ics" />
|
||||
</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
|
||||
ACTION_INSERT on the events *dir* mime type, carrying the new
|
||||
event's fields as CalendarContract extras
|
||||
(MainActivity.insertFormOrNull, issue #30). -->
|
||||
(MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
|
||||
dir mime is AOSP's "edit a new event" — i.e. create — so it maps
|
||||
to the same prefilled create form. (The far more common *item*-
|
||||
typed INSERT — the form the Android docs' example and apps like
|
||||
DB Navigator use — is the item filter below, issue #74.) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.INSERT" />
|
||||
<action android:name="android.intent.action.EDIT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="vnd.android.cursor.dir/event" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||
<!-- Create or edit an event another app/assistant/widget points at,
|
||||
addressed by the provider's *item* MIME type. Three actions share
|
||||
this filter, told apart at runtime by the intent's data:
|
||||
|
||||
• ACTION_INSERT — create. This is the form the Android docs'
|
||||
"insert an event" example and many apps use
|
||||
(setType("vnd.android.cursor.item/event")), e.g. DB Navigator's
|
||||
"Save to calendar". The dir-typed filter above alone missed it,
|
||||
so Calendula never showed in the chooser — and, when it was the
|
||||
only calendar app, the intent resolved to nothing (issue #74).
|
||||
• ACTION_INSERT_OR_EDIT — the third "add to calendar" action AOSP
|
||||
and Google Calendar register; a create, or an edit when it
|
||||
carries an event id.
|
||||
• ACTION_EDIT — edit the existing event at
|
||||
content://com.android.calendar/events/<id> (an id-less EDIT is a
|
||||
create). Opens the occurrence in the edit form, not the
|
||||
read-only detail — that's the VIEW filter above.
|
||||
|
||||
Create fields ride as CalendarContract extras; an edit's
|
||||
occurrence times ride as EXTRA_EVENT_BEGIN_TIME /
|
||||
EXTRA_EVENT_END_TIME when supplied (MainActivity.insertFormOrNull
|
||||
/ editEventKeyOrNull). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.INSERT" />
|
||||
<action android:name="android.intent.action.INSERT_OR_EDIT" />
|
||||
<action android:name="android.intent.action.EDIT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Open an existing event another app/widget points at (e.g. tapping
|
||||
an event in the Todo Agenda widget): ACTION_VIEW on
|
||||
content://com.android.calendar/events/<id>, the way AOSP fires it.
|
||||
Matched by the provider's item MIME type, not the path — a
|
||||
content: VIEW intent carries the resolved type
|
||||
(vnd.android.cursor.item/event) and a path-only filter wouldn't
|
||||
match it. The occurrence's times ride as EXTRA_EVENT_BEGIN_TIME /
|
||||
EXTRA_EVENT_END_TIME when the launcher supplies them
|
||||
(MainActivity.viewEventKeyOrNull, issue #48). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<!-- Launcher entry for MainActivity, split into two aliases so the app's
|
||||
launcher name can be switched at runtime between "Calendula" and
|
||||
"Calendar" (issue #44). Exactly one is enabled at a time; the app
|
||||
flips them via PackageManager.setComponentEnabledSetting
|
||||
(LauncherNameManager). The shortcuts meta-data lives here, not on
|
||||
MainActivity, because static shortcuts are published by whichever
|
||||
component owns the MAIN/LAUNCHER filter. -->
|
||||
<activity-alias
|
||||
android:name=".DefaultNameAlias"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:targetActivity=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
</activity-alias>
|
||||
<activity-alias
|
||||
android:name=".CalendarNameAlias"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name_calendar_alias"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:targetActivity=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity-alias>
|
||||
|
||||
<!-- Standalone surface for a captured crash report. MainActivity routes
|
||||
here on a startup crash-loop, so it stays clear of the app's Hilt
|
||||
@@ -152,17 +278,22 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
||||
no notification itself — a calendar app must (v1.4, Etar model).
|
||||
Exported: the broadcast arrives from the provider's process. -->
|
||||
<!-- Reminder delivery is the app's own (#75): it plans the alarms from
|
||||
Instances + Reminders instead of waiting for the provider's
|
||||
EVENT_REMINDER broadcast, which OEM-modified providers demonstrably
|
||||
retarget or never send. This receiver takes our scan alarm plus
|
||||
every outside event that invalidates it — boot and package-replace
|
||||
wipe pending alarms, and a clock or timezone change moves every
|
||||
reminder relative to the one that is armed.
|
||||
Exported: the system broadcasts arrive from outside the app. -->
|
||||
<receiver
|
||||
android:name=".data.reminders.EventReminderReceiver"
|
||||
android:name=".data.reminders.ReminderScheduleReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.EVENT_REMINDER" />
|
||||
<data
|
||||
android:host="com.android.calendar"
|
||||
android:scheme="content" />
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
<action android:name="android.intent.action.TIME_SET" />
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
|
||||
@@ -4,10 +4,14 @@ import android.app.Application
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
|
||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -24,10 +28,51 @@ class CalendulaApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Install first thing so startup crashes are captured too (privacy-
|
||||
// respecting, on-device; the user submits the report by hand).
|
||||
CrashReporter.install(this)
|
||||
// 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.
|
||||
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()
|
||||
reconcileSpecialDates()
|
||||
reconcileCalendarVisibility()
|
||||
startReminderDelivery()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring reminder delivery up with the process (#75): a scan re-arms whatever
|
||||
* the system dropped and posts what a missed alarm still owes, then the
|
||||
* provider watch keeps edits re-planned. The daily worker is the backstop.
|
||||
*/
|
||||
private fun startReminderDelivery() {
|
||||
val deps = EntryPointAccessors.fromApplication(
|
||||
this, ReminderMaintenanceWorker.Deps::class.java,
|
||||
)
|
||||
val scanner = deps.reminderScanner()
|
||||
scanner.startWatchingProvider()
|
||||
scanner.scanInBackground()
|
||||
ReminderMaintenanceScheduler.apply(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any calendar switch-off not yet written to `Calendars.VISIBLE`,
|
||||
* including the set inherited from the retired app-local model (#75). A
|
||||
* no-op in the steady state; `RootScreen` re-runs it after a later grant.
|
||||
*/
|
||||
private fun reconcileCalendarVisibility() {
|
||||
val deps = EntryPointAccessors.fromApplication(
|
||||
this, CalendarVisibilityReconciler.Deps::class.java,
|
||||
)
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
deps.calendarVisibilityReconciler().run()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,10 +95,8 @@ class CalendulaApp : Application() {
|
||||
|
||||
/**
|
||||
* Re-arm (or cancel) the daily special-dates reconcile from the saved
|
||||
* settings on every launch — like [reconcileAutoBackup], this cancels
|
||||
* orphaned work after the feature is turned off and re-schedules after a
|
||||
* reinstall. The foreground trigger (RootScreen ON_RESUME) does the on-open
|
||||
* refresh, so no immediate run is needed here.
|
||||
* settings, like [reconcileAutoBackup]. The on-open refresh is RootScreen's
|
||||
* ON_RESUME trigger, so no immediate run is needed here.
|
||||
*/
|
||||
private fun reconcileSpecialDates() {
|
||||
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)
|
||||
|
||||
@@ -23,22 +23,24 @@ import androidx.core.net.toUri
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.DebugRibbon
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||
import de.jeanlucmakiola.floret.components.DebugRibbon
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||
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.calendulaTypography
|
||||
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||
@@ -69,6 +71,11 @@ class MainActivity : AppCompatActivity() {
|
||||
// CalendarHost, which opens it in the create form for review.
|
||||
private var requestedInsertForm by mutableStateOf<EventForm?>(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
|
||||
// crash-loop is handled out of band, before setContent — see below.
|
||||
@@ -90,10 +97,11 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
enableEdgeToEdge()
|
||||
requestedDetailKey = intent.detailKeyOrNull()
|
||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||
requestedNav = intent.navRequestOrNull()
|
||||
requestedImportUri = intent.importUriOrNull()
|
||||
requestedInsertForm = intent.insertFormOrNull()
|
||||
requestedEditKey = intent.editEventKeyOrNull()
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
setContent {
|
||||
// One activity-scoped SettingsViewModel drives both the theme here
|
||||
@@ -133,6 +141,7 @@ class MainActivity : AppCompatActivity() {
|
||||
CompositionLocalProvider(
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalShowHourLines provides settings.showHourLines,
|
||||
LocalSoftenColors provides settings.softenColors,
|
||||
) {
|
||||
RootScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -144,6 +153,8 @@ class MainActivity : AppCompatActivity() {
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
onInsertConsumed = { requestedInsertForm = null },
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = { requestedEditKey = null },
|
||||
)
|
||||
}
|
||||
// A persistent corner marker so a debug build is never
|
||||
@@ -178,10 +189,11 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,13 +214,21 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
/**
|
||||
* A prefilled new-event form from an external `ACTION_INSERT` launch — another
|
||||
* app or widget (e.g. Todo Agenda) asking us to create an event (issue #30).
|
||||
* The new event's fields ride as CalendarContract extras; anything omitted
|
||||
* falls back to the in-app "new event" defaults in [buildInsertEventForm].
|
||||
* A prefilled new-event form from an external launch asking us to create an
|
||||
* event — another app or widget (e.g. Todo Agenda) firing `ACTION_INSERT`
|
||||
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
|
||||
* event", i.e. create). The new event's fields ride as CalendarContract
|
||||
* extras; anything omitted falls back to the in-app "new event" defaults in
|
||||
* [buildInsertEventForm].
|
||||
*/
|
||||
private fun Intent.insertFormOrNull(): EventForm? {
|
||||
if (action != Intent.ACTION_INSERT) return null
|
||||
// ACTION_EDIT / ACTION_INSERT_OR_EDIT on an existing event route to the
|
||||
// edit form instead ([editEventKeyOrNull]); an id-less one is a create,
|
||||
// as is any plain ACTION_INSERT.
|
||||
val isCreate = action == Intent.ACTION_INSERT ||
|
||||
((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) &&
|
||||
editEventKeyOrNull() == null)
|
||||
if (!isCreate) return null
|
||||
return buildInsertEventForm(
|
||||
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
||||
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
||||
@@ -293,6 +313,56 @@ class MainActivity : AppCompatActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The detail key for an external "open this event" — ACTION_VIEW on
|
||||
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
|
||||
* tapping an existing event in the Todo Agenda widget, issue #48). Reuses the
|
||||
* same occurrence-key channel as reminder taps. The launcher passes the
|
||||
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME` when
|
||||
* it has them; a bare URI omits them, so we carry [NO_OCCURRENCE_TIME] and
|
||||
* [EventDetailViewModel] falls back to the event row's own DTSTART/DTEND
|
||||
* rather than rendering at the epoch.
|
||||
*/
|
||||
private fun Intent.viewEventKeyOrNull(): LongArray? {
|
||||
if (action != Intent.ACTION_VIEW) return null
|
||||
val uri = data ?: return null
|
||||
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||
val segments = uri.pathSegments
|
||||
if (segments.firstOrNull() != "events") return null
|
||||
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||
return longArrayOf(
|
||||
eventId,
|
||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
|
||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The occurrence key for an external "edit this event" — `ACTION_EDIT` on
|
||||
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
|
||||
* an assistant, task app, or widget that wants to open the event for editing
|
||||
* rather than viewing). Opens it in the edit form. Reuses the same
|
||||
* occurrence-key channel as reminder/view taps; the caller passes the
|
||||
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME`
|
||||
* when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and
|
||||
* [EventEditViewModel.openForEdit] falls back to the event row's own
|
||||
* DTSTART/DTEND. An id-less `ACTION_EDIT` — or `ACTION_INSERT_OR_EDIT`, which
|
||||
* some apps fire — is a create instead ([insertFormOrNull]).
|
||||
*/
|
||||
private fun Intent.editEventKeyOrNull(): LongArray? {
|
||||
if (action != Intent.ACTION_EDIT && action != Intent.ACTION_INSERT_OR_EDIT) return null
|
||||
val uri = data ?: return null
|
||||
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||
val segments = uri.pathSegments
|
||||
if (segments.firstOrNull() != "events") return null
|
||||
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||
return longArrayOf(
|
||||
eventId,
|
||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
|
||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// The calendar provider's authority/host. A date tap arrives as
|
||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package de.jeanlucmakiola.calendula.data.appname
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** The launcher label the app shows for itself (issue #44). */
|
||||
enum class LauncherName { CALENDULA, CALENDAR }
|
||||
|
||||
/** The two launcher aliases declared in the manifest. */
|
||||
enum class LauncherAlias { DEFAULT, CALENDAR }
|
||||
|
||||
/** One component-enable change in a [aliasWritePlan]. */
|
||||
data class AliasStateChange(val alias: LauncherAlias, val enabled: Boolean)
|
||||
|
||||
/**
|
||||
* Interpret the `CalendarNameAlias` component-enabled state as a [LauncherName].
|
||||
* Only [PackageManager.COMPONENT_ENABLED_STATE_ENABLED] means the "Calendar"
|
||||
* name is active; `DEFAULT` (never toggled) and `DISABLED` both resolve to the
|
||||
* manifest default, "Calendula".
|
||||
*/
|
||||
fun launcherNameFor(calendarAliasState: Int): LauncherName =
|
||||
if (calendarAliasState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
|
||||
LauncherName.CALENDAR
|
||||
} else {
|
||||
LauncherName.CALENDULA
|
||||
}
|
||||
|
||||
/**
|
||||
* The ordered enable/disable steps to switch the launcher name to [target].
|
||||
* Always **enables the target alias first, then disables the other** — the two
|
||||
* `setComponentEnabledSetting` calls are not atomic, and this ordering means the
|
||||
* only transient the launcher can observe is a harmless two-entry state, never a
|
||||
* zero-entry one (which would briefly drop the app from the launcher).
|
||||
*/
|
||||
fun aliasWritePlan(target: LauncherName): List<AliasStateChange> = when (target) {
|
||||
LauncherName.CALENDAR -> listOf(
|
||||
AliasStateChange(LauncherAlias.CALENDAR, enabled = true),
|
||||
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
|
||||
)
|
||||
LauncherName.CALENDULA -> listOf(
|
||||
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
|
||||
AliasStateChange(LauncherAlias.CALENDAR, enabled = false),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the app's launcher label between "Calendula" and "Calendar" by
|
||||
* enabling/disabling the two `<activity-alias>` components (issue #44). The
|
||||
* component-enabled state is the single source of truth — there is no persisted
|
||||
* preference — so [current] reads it straight from [PackageManager] and the two
|
||||
* always agree.
|
||||
*
|
||||
* The decision logic lives in the pure [launcherNameFor] / [aliasWritePlan]
|
||||
* functions above (JVM-tested); this class is only the thin framework seam.
|
||||
*/
|
||||
@Singleton
|
||||
class LauncherNameManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val packageManager: PackageManager get() = context.packageManager
|
||||
|
||||
// The class portion is namespace-qualified (suffix-free), the package portion
|
||||
// is the applicationId (which carries the .debug / .releasetest suffix). The
|
||||
// manifest's ".CalendarNameAlias" resolves its class against the namespace, so
|
||||
// the real component is <namespace>.CalendarNameAlias registered under the
|
||||
// suffixed applicationId. Do NOT use ComponentName(context, ".CalendarNameAlias")
|
||||
// — its leading-dot form prepends the applicationId to the class too, producing
|
||||
// "<appId>.CalendarNameAlias" and failing on every debug / releaseTest build.
|
||||
private fun component(alias: LauncherAlias): ComponentName {
|
||||
val simpleName = when (alias) {
|
||||
LauncherAlias.DEFAULT -> "DefaultNameAlias"
|
||||
LauncherAlias.CALENDAR -> "CalendarNameAlias"
|
||||
}
|
||||
return ComponentName(context.packageName, "$NAMESPACE.$simpleName")
|
||||
}
|
||||
|
||||
/** The launcher name currently in effect. */
|
||||
fun current(): LauncherName =
|
||||
launcherNameFor(packageManager.getComponentEnabledSetting(component(LauncherAlias.CALENDAR)))
|
||||
|
||||
/** Switch the launcher name to [name] (no-op cost if already active). */
|
||||
fun set(name: LauncherName) {
|
||||
for (change in aliasWritePlan(name)) {
|
||||
val state = if (change.enabled) {
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
} else {
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||
}
|
||||
packageManager.setComponentEnabledSetting(
|
||||
component(change.alias),
|
||||
state,
|
||||
PackageManager.DONT_KILL_APP,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* The app's namespace (R-class package) — NOT `applicationId`, which
|
||||
* carries the build-type suffix. Kept in sync with `namespace` in
|
||||
* `app/build.gradle.kts`.
|
||||
*/
|
||||
const val NAMESPACE = "de.jeanlucmakiola.calendula"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import java.time.Instant
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.allDayLeadDays
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
* Translates an all-day reminder between the **semantic** lead time the UI
|
||||
@@ -73,19 +72,13 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
|
||||
|
||||
/**
|
||||
* Recover the semantic whole-day lead time from a raw all-day reminder
|
||||
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
|
||||
* returns the right day count regardless of which [timeOfDayMinutes] wrote the
|
||||
* row — including pre-feature rows (raw multiples of 1440, fired at UTC midnight)
|
||||
* and rows written under a different timezone. A negative [rawMinutes] (fire
|
||||
* after DTSTART) folds to day 0.
|
||||
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
|
||||
* detail screen. Delegates to [allDayLeadDays], so the day displayed is the day
|
||||
* the reminder actually fires on.
|
||||
*/
|
||||
internal fun fromProviderAllDayMinutes(
|
||||
rawMinutes: Int,
|
||||
startDate: LocalDate,
|
||||
zone: ZoneId,
|
||||
): Int {
|
||||
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
val fireLocalDate = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE)
|
||||
.atZone(zone).toLocalDate()
|
||||
return ChronoUnit.DAYS.between(fireLocalDate, startDate).toInt() * MINUTES_PER_DAY
|
||||
}
|
||||
timeOfDayMinutes: Int,
|
||||
): Int = allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import android.Manifest
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
@@ -16,10 +18,13 @@ 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
|
||||
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
@@ -49,7 +54,12 @@ import javax.inject.Singleton
|
||||
interface CalendarDataSource {
|
||||
fun calendars(): List<CalendarSource>
|
||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||
fun eventDetail(eventId: Long): EventDetail?
|
||||
/**
|
||||
* [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to
|
||||
* decode their stored offsets back to whole-day lead times (see
|
||||
* [fromProviderAllDayMinutes]).
|
||||
*/
|
||||
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
|
||||
|
||||
/**
|
||||
* Master/one-off events whose title, description or location contains
|
||||
@@ -61,19 +71,23 @@ interface CalendarDataSource {
|
||||
|
||||
/**
|
||||
* The event-colour palette the calendar's account publishes
|
||||
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. 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.
|
||||
* (`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.
|
||||
*/
|
||||
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||
|
||||
/**
|
||||
* Every master/one-off event of the writable local calendars, mapped for a
|
||||
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
||||
* rows are excluded (see [EventExportProjection]).
|
||||
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
|
||||
* given, only those calendars are exported (still intersected with the
|
||||
* eligible set); `null` exports every eligible calendar.
|
||||
*/
|
||||
fun exportableEvents(): List<IcsEvent>
|
||||
fun exportableEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
|
||||
|
||||
/**
|
||||
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||
@@ -101,6 +115,28 @@ interface CalendarDataSource {
|
||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||
fun deleteCalendar(id: Long)
|
||||
|
||||
/**
|
||||
* Show or hide the calendar device-wide by writing `Calendars.VISIBLE` — the
|
||||
* app's one visibility model (#75), which also gates its reminders. One of
|
||||
* the three columns the platform documents as app-writable, and device-local.
|
||||
*/
|
||||
fun setCalendarVisible(id: Long, visible: Boolean)
|
||||
|
||||
/**
|
||||
* Whether one calendar is switched on at system level, without reading every
|
||||
* row — for the reminder gate, which sees only a calendar id. Null when the
|
||||
* answer can't be had: no row, or no read permission.
|
||||
*/
|
||||
fun isCalendarVisible(id: Long): Boolean?
|
||||
|
||||
/**
|
||||
* Whether the app holds `WRITE_CALENDAR`, i.e. may write
|
||||
* [setCalendarVisible] at all. Read-only users (READ granted, WRITE denied)
|
||||
* keep their calendar switches app-side instead — see
|
||||
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds].
|
||||
*/
|
||||
fun canWriteCalendars(): Boolean
|
||||
|
||||
/**
|
||||
* Create a local calendar tagged as the special-dates mirror for [type]
|
||||
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
|
||||
@@ -172,6 +208,25 @@ interface CalendarDataSource {
|
||||
allDayReminderTimeMinutes: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Move an event (for recurring events: the whole series, with its modified
|
||||
* 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 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].
|
||||
*/
|
||||
fun moveEvent(
|
||||
eventId: Long,
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long
|
||||
|
||||
/**
|
||||
* Change a single occurrence of a recurring event by inserting a
|
||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||
@@ -345,6 +400,37 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
||||
}
|
||||
|
||||
/**
|
||||
* Addressed by appended id on the plain (non-sync-adapter) Calendars URI, one
|
||||
* calendar per call. Both parts are load-bearing:
|
||||
* `CalendarProvider2.updateInTransaction` skips the dirty marking and the
|
||||
* `checkNextAlarm()` reschedule unless the selection is `_id=…`, and the
|
||||
* plain URI is what makes the write apply to synced calendars too.
|
||||
*/
|
||||
override fun setCalendarVisible(id: Long, visible: Boolean) {
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.VISIBLE, if (visible) 1 else 0)
|
||||
}
|
||||
val rows = resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, id),
|
||||
values, null, null,
|
||||
)
|
||||
if (rows == 0) throw WriteFailedException("set calendar visibility id=$id")
|
||||
}
|
||||
|
||||
override fun isCalendarVisible(id: Long): Boolean? {
|
||||
if (!hasCalendarPermission()) return null
|
||||
return resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, id),
|
||||
arrayOf(CalendarContract.Calendars.VISIBLE),
|
||||
null, null, null,
|
||||
)?.use { if (it.moveToFirst()) it.getInt(0) != 0 else null }
|
||||
}
|
||||
|
||||
override fun canWriteCalendars(): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||
val values = ContentValues().apply {
|
||||
@@ -488,7 +574,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
return resolver.query(
|
||||
uri,
|
||||
InstanceProjection.COLUMNS,
|
||||
null, null,
|
||||
// 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).
|
||||
"${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}",
|
||||
null,
|
||||
CalendarContract.Instances.BEGIN + " ASC",
|
||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||
}
|
||||
@@ -576,7 +668,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventDetail(eventId: Long): EventDetail? {
|
||||
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? {
|
||||
val attendees = queryAttendees(eventId)
|
||||
val reminders = queryReminders(eventId)
|
||||
return resolver.query(
|
||||
@@ -585,7 +677,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
null, null, null,
|
||||
)?.use { c ->
|
||||
if (!c.moveToFirst()) null
|
||||
else CursorColumnReader(c).toEventDetailCore(attendees, reminders)
|
||||
else CursorColumnReader(c).toEventDetailCore(
|
||||
attendees, reminders, allDayReminderTimeMinutes,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,16 +701,23 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
||||
}
|
||||
?.filter { it.key.isNotEmpty() }
|
||||
?.sortedBy { it.key }
|
||||
?.curatedForPicker()
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
override fun exportableEvents(): List<IcsEvent> {
|
||||
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
|
||||
// Only the local calendars the app owns and can write — synced calendars
|
||||
// already have a backup (their server). Map id → display name for the
|
||||
// already have a backup (their server). Exclude the managed special-dates
|
||||
// mirror calendars: their events are derived from contacts, not authored
|
||||
// here, and re-materialise from the contact sync — backing them up would
|
||||
// just duplicate them on restore. A non-null [calendarIds] narrows the
|
||||
// export to the user's chosen subset. Map id → display name for the
|
||||
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
|
||||
val names = calendars()
|
||||
.filter { it.isLocal && it.canModifyContents }
|
||||
.filter {
|
||||
it.isLocal && it.canModifyContents && !it.isManaged &&
|
||||
(calendarIds == null || it.id in calendarIds)
|
||||
}
|
||||
.associate { it.id to it.displayName }
|
||||
if (names.isEmpty()) return emptyList()
|
||||
|
||||
@@ -865,6 +966,190 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun moveEvent(
|
||||
eventId: Long,
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long {
|
||||
// CALENDAR_ID can't be updated in place, so re-create the event on the
|
||||
// target calendar and delete the source. Everything that identifies or
|
||||
// hangs off the event is copied first; the source row is removed only
|
||||
// once the copy (including its exceptions) is complete — a failure
|
||||
// anywhere before that rolls the new event back, so the move is
|
||||
// all-or-nothing and never leaves a half-copied duplicate.
|
||||
val master = queryMoveMaster(eventId)
|
||||
?: 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() } ?: "${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")
|
||||
try {
|
||||
copyReminderRows(fromEventId = eventId, toEventId = newEventId)
|
||||
insertAttendees(newEventId, editableAttendees(eventId))
|
||||
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
|
||||
// (including a time or rrule edit made in the same save).
|
||||
updateEvent(newEventId, original, updated, allDayReminderTimeMinutes)
|
||||
} catch (t: Throwable) {
|
||||
// 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) }
|
||||
throw t
|
||||
}
|
||||
deleteEvent(eventId)
|
||||
return newEventId
|
||||
}
|
||||
|
||||
/** The master row of the event to move, as a verbatim-insert snapshot. */
|
||||
private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
MoveMasterProjection.COLUMNS,
|
||||
null, null, null,
|
||||
)?.use { c ->
|
||||
if (!c.moveToFirst()) return@use null
|
||||
val r = CursorColumnReader(c)
|
||||
MoveMaster(
|
||||
uid = r.getString(MoveMasterProjection.IDX_UID),
|
||||
event = MasterEventSnapshot(
|
||||
title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(),
|
||||
isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0,
|
||||
dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART),
|
||||
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),
|
||||
exdate = r.getString(MoveMasterProjection.IDX_EXDATE),
|
||||
timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE),
|
||||
availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY),
|
||||
accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL),
|
||||
status = r.getInt(MoveMasterProjection.IDX_STATUS)
|
||||
.takeUnless { r.isNull(MoveMasterProjection.IDX_STATUS) },
|
||||
location = r.getString(MoveMasterProjection.IDX_LOCATION),
|
||||
description = r.getString(MoveMasterProjection.IDX_DESCRIPTION),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private data class MoveMaster(val uid: String?, val event: MasterEventSnapshot)
|
||||
|
||||
/** Copy every stored reminder row (raw offsets and method) onto [toEventId]. */
|
||||
private fun copyReminderRows(fromEventId: Long, toEventId: Long) = resolver.query(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Reminders.MINUTES, CalendarContract.Reminders.METHOD),
|
||||
CalendarContract.Reminders.EVENT_ID + " = ?",
|
||||
arrayOf(fromEventId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
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
|
||||
|
||||
/**
|
||||
* The event's editable guests as [EventAttendee]s — the rows the move
|
||||
* re-creates. Mirrors the edit form's filter: the organizer and resource
|
||||
* rows (backend-owned, not user-editable) and any without an email are
|
||||
* dropped; the response status resets to "invited" on re-insert.
|
||||
*/
|
||||
private fun editableAttendees(eventId: Long): List<EventAttendee> = queryAttendees(eventId)
|
||||
.filter {
|
||||
it.relationship != AttendeeRelationship.Organizer && it.type != AttendeeType.Resource
|
||||
}
|
||||
.mapNotNull { a ->
|
||||
a.email?.takeIf { it.isNotBlank() }?.let { email ->
|
||||
EventAttendee(email = email, name = a.name, optional = a.type == AttendeeType.Optional)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The series' exception rows (modified + cancelled), oldest occurrence first. */
|
||||
private fun queryExceptionRows(seriesEventId: Long): List<ExceptionRowSnapshot> = resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
ExceptionProjection.COLUMNS,
|
||||
"${CalendarContract.Events.ORIGINAL_ID} = ? AND ${CalendarContract.Events.DELETED} = 0",
|
||||
arrayOf(seriesEventId.toString()),
|
||||
CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC",
|
||||
)?.use { c ->
|
||||
c.mapAll {
|
||||
val r = CursorColumnReader(c)
|
||||
val status = r.getInt(ExceptionProjection.IDX_STATUS)
|
||||
.takeUnless { r.isNull(ExceptionProjection.IDX_STATUS) }
|
||||
ExceptionRowSnapshot(
|
||||
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()
|
||||
|
||||
override fun updateOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
@@ -959,6 +1244,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
CalendarContract.Events.EVENT_TIMEZONE,
|
||||
CalendarContract.Events.DURATION,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events._SYNC_ID,
|
||||
CalendarContract.Events.EXDATE,
|
||||
),
|
||||
null, null, null,
|
||||
)?.use { c ->
|
||||
@@ -969,6 +1256,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
timezone = c.getString(2),
|
||||
duration = c.getString(3),
|
||||
allDay = c.getInt(4),
|
||||
syncId = c.getString(5),
|
||||
exdate = c.getString(6),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -981,6 +1270,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
val timezone: String?,
|
||||
val duration: String?,
|
||||
val allDay: Int,
|
||||
/** Null on a local calendar (and before a synced event's first push). */
|
||||
val syncId: String? = null,
|
||||
val exdate: String? = null,
|
||||
) {
|
||||
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
||||
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
||||
@@ -1171,16 +1463,49 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
|
||||
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||
// A cancelled exception row hides exactly this occurrence; the sync
|
||||
// adapter turns it into an EXDATE/cancelled VEVENT upstream.
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, beginMillis)
|
||||
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||
val row = querySeriesRow(eventId)
|
||||
if (row.syncId == null) {
|
||||
// No _sync_id — a local calendar, or a synced event not pushed yet.
|
||||
// A cancelled exception can only attach to its parent through
|
||||
// ORIGINAL_SYNC_ID, so with none the link never forms and the
|
||||
// provider's expansion of the *parent* collapses, taking every other
|
||||
// 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.
|
||||
val values = buildOccurrenceExdateValues(
|
||||
existingExdate = row.exdate,
|
||||
occurrenceMillis = beginMillis,
|
||||
dtStartMillis = row.dtStartMillis,
|
||||
rrule = row.rrule,
|
||||
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
|
||||
// adapter turns it into an EXDATE/cancelled VEVENT upstream. It carries
|
||||
// the full time set (DTSTART + DURATION + zone) so the provider derives a
|
||||
// single instance rather than cloning the master's RRULE — the same trap
|
||||
// the edit path documents (Codeberg #16).
|
||||
val values = buildOccurrenceCancelValues(
|
||||
originalInstanceMillis = beginMillis,
|
||||
dtStartMillis = beginMillis,
|
||||
duration = row.duration,
|
||||
timezone = row.timezone,
|
||||
allDay = row.allDay,
|
||||
)
|
||||
val uri = ContentUris.withAppendedId(
|
||||
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
|
||||
)
|
||||
resolver.insert(uri, values)
|
||||
resolver.insert(uri, values.toContentValues())
|
||||
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
|
||||
}
|
||||
|
||||
|
||||
@@ -31,5 +31,8 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
||||
isManaged = isLocal &&
|
||||
getString(CalendarProjection.IDX_MANAGED_MARKER)
|
||||
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
|
||||
// NULL is treated as syncing — the harmless default.
|
||||
syncsEvents = isNull(CalendarProjection.IDX_SYNC_EVENTS) ||
|
||||
getInt(CalendarProjection.IDX_SYNC_EVENTS) != 0,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,11 +38,22 @@ interface CalendarRepository {
|
||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||
suspend fun deleteCalendar(id: Long)
|
||||
|
||||
/**
|
||||
* Show or hide [ids] device-wide (`Calendars.VISIBLE`), which also gates
|
||||
* their reminders — see [CalendarDataSource.setCalendarVisible]. Written one
|
||||
* at a time, in order; a failure part-way leaves the earlier writes standing.
|
||||
*
|
||||
* Without `WRITE_CALENDAR` the choice is kept app-side instead (see
|
||||
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds]).
|
||||
*/
|
||||
suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean)
|
||||
|
||||
/**
|
||||
* Every event of the writable local calendars, ready to serialise into a
|
||||
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
||||
* [calendarIds] narrows the export to a chosen subset; `null` exports all.
|
||||
*/
|
||||
suspend fun exportEvents(): List<IcsEvent>
|
||||
suspend fun exportEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
|
||||
|
||||
/**
|
||||
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
|
||||
@@ -60,6 +71,20 @@ interface CalendarRepository {
|
||||
*/
|
||||
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
||||
|
||||
/**
|
||||
* Move an event (recurring: the whole series, with its exceptions) to
|
||||
* [targetCalendarId] and apply the [original]→[updated] field edits; returns
|
||||
* the new event's `Events._ID`. Copy+delete under the hood
|
||||
* (see [CalendarDataSource.moveEvent]) — `CALENDAR_ID` can't be updated in
|
||||
* place.
|
||||
*/
|
||||
suspend fun moveEvent(
|
||||
eventId: Long,
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long
|
||||
|
||||
/**
|
||||
* Change a single occurrence of a recurring event (exception row with the
|
||||
* form's values); returns the exception's `Events._ID`.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
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
|
||||
@@ -15,11 +16,17 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -46,52 +53,120 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
extraBufferCapacity = 1,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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 { ticks.tryEmit(Unit) }
|
||||
dataSource.registerChangeListener {
|
||||
generation.incrementAndGet()
|
||||
ticks.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
override fun calendars(): Flow<List<CalendarSource>> =
|
||||
ticks
|
||||
.onStart { emit(Unit) }
|
||||
.reQuery { dataSource.calendars() }
|
||||
.flowOn(io)
|
||||
/**
|
||||
* Re-query signal for everything filtered by visibility: the provider's own
|
||||
* notifications, plus every change to the pending switch-off set.
|
||||
*/
|
||||
private fun visibilityTicks(): Flow<Unit> = merge(
|
||||
ticks.onStart { emit(Unit) },
|
||||
// drop(1): the current value is already covered by the tick above.
|
||||
prefs.pendingDisabledCalendarIds.drop(1).map {},
|
||||
)
|
||||
|
||||
// Instances are filtered by the app-side hidden ∪ disabled calendar sets
|
||||
// (M3): an event is dropped whenever the user has hidden *or* disabled its
|
||||
// calendar. Re-runs when the provider ticks *or* either set changes —
|
||||
// toggling a calendar in the filter sheet or the calendar manager updates
|
||||
// every view immediately. [calendars] stays unfiltered so those screens can
|
||||
// list and re-enable hidden/disabled calendars.
|
||||
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
||||
combine(
|
||||
ticks
|
||||
.onStart { emit(Unit) }
|
||||
.reQuery {
|
||||
dataSource.instances(
|
||||
beginMillis = range.start.toEpochMillis(),
|
||||
endMillis = range.endInclusive.toEpochMillis(),
|
||||
)
|
||||
},
|
||||
prefs.hiddenCalendarIds,
|
||||
prefs.disabledCalendarIds,
|
||||
) { instances, hidden, disabled ->
|
||||
val excluded = hidden + disabled
|
||||
if (excluded.isEmpty()) instances
|
||||
else instances.filterNot { it.calendarId in excluded }
|
||||
// A switch-off not yet written to the provider is folded into the flag
|
||||
// itself, so every consumer reads one visibility (#75). The reconciler goes
|
||||
// to the data source directly — it needs the provider's own answer.
|
||||
override fun calendars(): Flow<List<CalendarSource>> =
|
||||
visibilityTicks().reQuery {
|
||||
val calendars = calendarsSnapshot()
|
||||
val pendingDisabled = prefs.pendingDisabledCalendarIds.first()
|
||||
if (pendingDisabled.isEmpty()) calendars
|
||||
else calendars.map {
|
||||
if (it.id in pendingDisabled) it.copy(isVisibleInSystem = false) else it
|
||||
}
|
||||
}
|
||||
// hidden and disabled both derive from one DataStore, so toggling
|
||||
// either makes both re-emit and combine briefly surfaces the same
|
||||
// list twice — collapse the duplicate so views don't re-render for it.
|
||||
.distinctUntilChanged()
|
||||
.flowOn(io)
|
||||
|
||||
// Instances are filtered by the system's VISIBLE flag ∪ the switch-offs
|
||||
// still waiting to be written to it ∪ the app-side hidden set from the
|
||||
// filter sheet. [calendars] stays unfiltered so those screens can list and
|
||||
// re-enable invisible calendars.
|
||||
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
||||
combine(
|
||||
visibilityTicks().reQuery {
|
||||
// All three reads in one pass, so a list of instances is never
|
||||
// filtered against a visibility snapshot from another tick.
|
||||
QueriedInstances(
|
||||
instances = dataSource.instances(
|
||||
beginMillis = range.start.toEpochMillis(),
|
||||
endMillis = range.endInclusive.toEpochMillis(),
|
||||
),
|
||||
switchedOffCalendarIds = invisibleCalendarIds() +
|
||||
prefs.pendingDisabledCalendarIds.first(),
|
||||
)
|
||||
},
|
||||
prefs.hiddenCalendarIds,
|
||||
) { queried, hidden ->
|
||||
val excluded = hidden + queried.switchedOffCalendarIds
|
||||
if (excluded.isEmpty()) queried.instances
|
||||
else queried.instances.filterNot { it.calendarId in excluded }
|
||||
}
|
||||
// Any DataStore edit re-emits the hidden set even when unchanged;
|
||||
// collapse those so views don't re-render for them.
|
||||
.distinctUntilChanged()
|
||||
.flowOn(io)
|
||||
|
||||
/** One instances query plus the visibility it must be filtered against. */
|
||||
private data class QueriedInstances(
|
||||
val instances: List<EventInstance>,
|
||||
val switchedOffCalendarIds: Set<Long>,
|
||||
)
|
||||
|
||||
/** Calendars switched off at system level — hidden, and never reminded about. */
|
||||
private suspend fun invisibleCalendarIds(): Set<Long> = calendarsSnapshot()
|
||||
.filterNot { it.isVisibleInSystem }
|
||||
.mapTo(mutableSetOf()) { it.id }
|
||||
|
||||
private val calendarsLock = Mutex()
|
||||
private var cachedGeneration = -1L
|
||||
private var cachedPending: Set<Long>? = null
|
||||
private var cachedCalendars: List<CalendarSource> = emptyList()
|
||||
|
||||
/**
|
||||
* The calendar list for the current tick, queried once and shared, so every
|
||||
* open view doesn't pay for its own `Calendars` query and all of them see
|
||||
* one snapshot.
|
||||
*
|
||||
* An empty result is never cached — that is what a read without the calendar
|
||||
* permission returns, and the grant itself doesn't notify the provider. The
|
||||
* pending switch-off set keys the cache alongside the tick, because an id
|
||||
* leaves it before the invalidating observer is dispatched.
|
||||
*/
|
||||
private suspend fun calendarsSnapshot(): List<CalendarSource> = calendarsLock.withLock {
|
||||
val current = generation.get()
|
||||
val pending = prefs.pendingDisabledCalendarIds.first()
|
||||
if (current != cachedGeneration || pending != cachedPending || cachedCalendars.isEmpty()) {
|
||||
cachedCalendars = dataSource.calendars()
|
||||
cachedGeneration = current
|
||||
cachedPending = pending
|
||||
}
|
||||
cachedCalendars
|
||||
}
|
||||
|
||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||
dataSource.eventDetail(eventId, allDayReminderTimeMinutes())
|
||||
?: throw NoSuchEventException(eventId)
|
||||
}
|
||||
|
||||
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
||||
if (query.isBlank()) return@withContext emptyList()
|
||||
val excluded = prefs.hiddenCalendarIds.first() + prefs.disabledCalendarIds.first()
|
||||
val excluded = prefs.hiddenCalendarIds.first() +
|
||||
prefs.pendingDisabledCalendarIds.first() +
|
||||
invisibleCalendarIds()
|
||||
dataSource.searchEvents(query)
|
||||
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
||||
}
|
||||
@@ -117,7 +192,24 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
override suspend fun deleteCalendar(id: Long) =
|
||||
withContext(io) { dataSource.deleteCalendar(id) }
|
||||
|
||||
override suspend fun exportEvents() = withContext(io) { dataSource.exportableEvents() }
|
||||
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
||||
withContext(io) { dataSource.exportableEvents(calendarIds) }
|
||||
|
||||
override suspend fun importEvents(
|
||||
targetCalendarId: Long,
|
||||
@@ -155,6 +247,17 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
dataSource.deleteEvent(eventId)
|
||||
}
|
||||
|
||||
override suspend fun moveEvent(
|
||||
eventId: Long,
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long = withContext(io) {
|
||||
dataSource.moveEvent(
|
||||
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun updateOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
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 kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Keeps [CalendarPrefs.pendingDisabledCalendarIds] and the system's
|
||||
* `Calendars.VISIBLE` in step (#75): the fold-in of the retired app-local
|
||||
* visibility model, and the standing drain for switch-offs made without
|
||||
* `WRITE_CALENDAR`.
|
||||
*
|
||||
* Runs on every launch and on every calendar-permission grant; a no-op once the
|
||||
* pending set is empty and the notice is settled. Only ever hides (see
|
||||
* [calendarVisibilityPlan]); on an upgraded install the first run that sees a
|
||||
* system-hidden calendar arms the one-time explanatory notice.
|
||||
*/
|
||||
@Singleton
|
||||
class CalendarVisibilityReconciler @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val dataSource: CalendarDataSource,
|
||||
private val prefs: CalendarPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
suspend fun run() = withContext(io) {
|
||||
// The DataStore reads sit inside the guard too: this runs in a bare
|
||||
// application-scope coroutine, so an IOException from a damaged
|
||||
// preferences file would take the process down on every launch.
|
||||
try {
|
||||
// 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 (!hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
|
||||
val pending = prefs.pendingDisabledCalendarIds.first()
|
||||
val noticeSettled = prefs.visibilityNoticePending.first() != null
|
||||
if (pending.isEmpty() && noticeSettled) return@withContext
|
||||
val calendars = dataSource.calendars()
|
||||
// Empty means "couldn't read" (null cursor), not "no calendars".
|
||||
// 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() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
||||
return@withContext
|
||||
}
|
||||
val plan = calendarVisibilityPlan(calendars, pending)
|
||||
prefs.removePendingDisabledCalendarIds(plan.settled)
|
||||
// One calendar per write: the provider skips its reminder-alarm
|
||||
// reschedule for anything but a single-id update (see
|
||||
// [CalendarDataSource.setCalendarVisible]).
|
||||
for (id in plan.hide) {
|
||||
dataSource.setCalendarVisible(id, false)
|
||||
prefs.removePendingDisabledCalendarIds(setOf(id))
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Calendar visibility reconcile failed; will retry", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle the one-time notice: [pending] arms it, false retires it unshown.
|
||||
* Answered once and stored either way, so it can't resurface later.
|
||||
*/
|
||||
private suspend fun settleNoticeOnce(pending: Boolean) {
|
||||
if (prefs.visibilityNoticePending.first() != null) return
|
||||
prefs.setVisibilityNoticePending(pending)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this install has ever run an earlier version. The notice explains
|
||||
* a behaviour change, so a first install has nothing to announce.
|
||||
*/
|
||||
private fun isUpgradeInstall(): Boolean = try {
|
||||
@Suppress("DEPRECATION")
|
||||
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
info.lastUpdateTime > info.firstInstallTime
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
Log.w(TAG, "Own package info unavailable; treating as a fresh install", e)
|
||||
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)
|
||||
interface Deps {
|
||||
fun calendarVisibilityReconciler(): CalendarVisibilityReconciler
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "CalendarVisibility"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
@@ -13,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
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
|
||||
@@ -22,6 +24,7 @@ private const val TAG = "EventDetailMapper"
|
||||
internal fun ColumnReader.toEventDetailCore(
|
||||
attendees: List<Attendee>,
|
||||
reminders: List<Reminder>,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): EventDetail? {
|
||||
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
||||
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
||||
@@ -35,15 +38,18 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||
|
||||
// Recurring events store DURATION instead of DTEND, so the series row's
|
||||
// DTEND is null. Keep the event (end == begin); callers that opened a
|
||||
// specific occurrence supply the real per-occurrence times from
|
||||
// CalendarContract.Instances. A present-but-backwards DTEND is malformed,
|
||||
// but dropping the row would make the event un-openable — 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's coerceAtLeast).
|
||||
// DTEND is null — derive the length from DURATION (as SearchMapper and
|
||||
// IcsExportMapper do). Callers that opened a specific occurrence overwrite
|
||||
// both times with the per-occurrence values from CalendarContract.Instances;
|
||||
// a caller that names no occurrence (a bare content://.../events/<id> VIEW
|
||||
// intent, issue #48) keeps this row's own times, so the length has to be
|
||||
// right here or the series renders zero-length. A present-but-backwards
|
||||
// DTEND is malformed, but dropping the row would make the event un-openable
|
||||
// — 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 = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||
begin
|
||||
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))
|
||||
} else {
|
||||
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
||||
}
|
||||
@@ -84,7 +90,13 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
val displayReminders = if (isAllDay) {
|
||||
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
val zone = ZoneId.systemDefault()
|
||||
reminders.map { it.copy(minutes = fromProviderAllDayMinutes(it.minutes, startDate, zone)) }
|
||||
reminders.map {
|
||||
it.copy(
|
||||
minutes = fromProviderAllDayMinutes(
|
||||
it.minutes, startDate, zone, allDayReminderTimeMinutes,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
reminders
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toJavaLocalDateTime
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.LocalDateTime as JavaLocalDateTime
|
||||
|
||||
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
||||
internal data class EventWriteTimes(
|
||||
@@ -20,7 +22,11 @@ internal data class EventWriteTimes(
|
||||
/**
|
||||
* All-day events live at UTC midnights with an exclusive DTEND (the
|
||||
* CalendarContract convention — a one-day event ends at the next midnight);
|
||||
* timed events resolve their wall-clock values in [zone].
|
||||
* timed events resolve their wall-clock values in the form's own
|
||||
* [EventForm.timezone], falling back to [zone] (the device) when it doesn't pin
|
||||
* one. Passing the device zone is therefore still correct for an unpinned form —
|
||||
* but it no longer overrides a pinned event's zone, which is what used to
|
||||
* silently re-anchor a foreign-zone event to the device on any time edit.
|
||||
*/
|
||||
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
|
||||
EventWriteTimes(
|
||||
@@ -31,22 +37,56 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
||||
timezone = "UTC",
|
||||
)
|
||||
} else {
|
||||
val writeZone = writeZone(zone)
|
||||
EventWriteTimes(
|
||||
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
||||
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
||||
timezone = zone.id,
|
||||
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||
timezone = writeZone.id,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The zone this form's DTSTART is expressed in: UTC for an all-day event (the
|
||||
* provider's date anchor), otherwise the form's own pinned zone, falling back to
|
||||
* [deviceZone] when it doesn't pin one or pins something the tz database can't
|
||||
* parse.
|
||||
*/
|
||||
private fun EventForm.writeZone(deviceZone: ZoneId): ZoneId = if (isAllDay) {
|
||||
ZoneOffset.UTC
|
||||
} else {
|
||||
timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: deviceZone
|
||||
}
|
||||
|
||||
/**
|
||||
* The form's start as a bare wall-clock value — what the user sees on the form,
|
||||
* stripped of any zone. All-day events use their date's midnight rather than
|
||||
* [EventForm.start]'s placeholder time-of-day, which exists only so switching the
|
||||
* event back to timed has something to show.
|
||||
*/
|
||||
private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) {
|
||||
start.date.toJavaLocalDate().atStartOfDay()
|
||||
} else {
|
||||
start.toJavaLocalDateTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 2445 duration for a recurring event's row (the provider requires
|
||||
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
||||
* events, seconds otherwise.
|
||||
*/
|
||||
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) {
|
||||
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D"
|
||||
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String =
|
||||
rfc2445Duration(dtEndMillis - dtStartMillis, isAllDay)
|
||||
|
||||
/**
|
||||
* RFC 2445 duration for a [spanMillis]-long event: whole days for all-day
|
||||
* events (the provider's convention), seconds otherwise. Shared by the write
|
||||
* paths that need a DURATION but start from a raw millisecond span (the series
|
||||
* copy and exception replay of a calendar move) rather than [EventWriteTimes].
|
||||
*/
|
||||
internal fun rfc2445Duration(spanMillis: Long, isAllDay: Boolean): String = if (isAllDay) {
|
||||
"P${spanMillis / MILLIS_PER_DAY}D"
|
||||
} else {
|
||||
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
||||
"P${spanMillis / 1_000L}S"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,10 +135,12 @@ internal fun buildEventInsertValues(
|
||||
* Time fields travel together (the provider validates them as a unit):
|
||||
* - unchanged times, all-day flag and rrule → no time columns at all;
|
||||
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
||||
* - recurring result → the *series* DTSTART moves by the same delta the user
|
||||
* applied to the displayed occurrence ([seriesDtStartMillis] is the row's
|
||||
* current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps
|
||||
* past occurrences intact when someone edits a later occurrence's time.
|
||||
* - recurring result → the *series* DTSTART moves by the same **wall-clock**
|
||||
* shift the user applied to the displayed occurrence and is re-resolved in the
|
||||
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
|
||||
* replaces DTEND, RRULE is written. This keeps past occurrences intact when
|
||||
* someone edits a later occurrence's time, and keeps the anchor's time-of-day
|
||||
* stable across a DST boundary or a zone change between the two.
|
||||
*/
|
||||
internal fun buildEventUpdateValues(
|
||||
original: EventForm,
|
||||
@@ -125,10 +167,14 @@ internal fun buildEventUpdateValues(
|
||||
putAll(eventColorColumns(updated.colorKey, updated.color))
|
||||
}
|
||||
|
||||
// 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.
|
||||
val timesChanged = updated.start != original.start ||
|
||||
updated.end != original.end ||
|
||||
updated.isAllDay != original.isAllDay ||
|
||||
updated.rrule != original.rrule
|
||||
updated.rrule != original.rrule ||
|
||||
updated.timezone != original.timezone
|
||||
if (!timesChanged) return@buildMap
|
||||
|
||||
val newTimes = updated.toWriteTimes(zone)
|
||||
@@ -140,8 +186,23 @@ internal fun buildEventUpdateValues(
|
||||
put(CalendarContract.Events.RRULE, null)
|
||||
put(CalendarContract.Events.DURATION, null)
|
||||
} else {
|
||||
val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis
|
||||
put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta)
|
||||
// Move the series anchor by the *wall-clock* shift the user applied to the
|
||||
// displayed occurrence, then re-resolve it in the event's (possibly new)
|
||||
// zone — never by a millisecond delta. An instant delta silently bakes in
|
||||
// the offset that happened to apply on the edited occurrence's date, which
|
||||
// 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 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
|
||||
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)
|
||||
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
||||
@@ -182,6 +243,215 @@ internal fun buildOccurrenceExceptionValues(
|
||||
putAll(eventColorColumns(form.colorKey, form.color))
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
|
||||
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
||||
* is sync-adapter-owned and can't be updated in place). Recurring rows carry
|
||||
* [rrule]/[duration] (and any [rdate]/[exdate]) with a null [dtEndMillis];
|
||||
* one-off rows carry [dtEndMillis]. Colour is deliberately absent: a raw
|
||||
* `EVENT_COLOR` or account-scoped `EVENT_COLOR_KEY` may be invalid on the target
|
||||
* account, so the moved copy inherits the target calendar's colour instead.
|
||||
*/
|
||||
internal data class MasterEventSnapshot(
|
||||
val title: String,
|
||||
val isAllDay: Boolean,
|
||||
val dtStartMillis: Long,
|
||||
val dtEndMillis: Long?,
|
||||
val duration: String?,
|
||||
val rrule: String?,
|
||||
val rdate: String?,
|
||||
val exdate: String?,
|
||||
val timezone: String?,
|
||||
val availability: Int,
|
||||
val accessLevel: Int,
|
||||
val status: Int?,
|
||||
val location: String?,
|
||||
val description: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Column values re-creating [snapshot] as a fresh Events row on
|
||||
* [targetCalendarId], keeping its [uid] so `.ics` backup dedup and sync identity
|
||||
* survive the move. Preserves the recurrence skeleton (DTSTART/RRULE/DURATION,
|
||||
* RDATE/EXDATE) so the series' generated instances — and therefore the
|
||||
* ORIGINAL_INSTANCE_TIME of every copied exception — line up unchanged. The
|
||||
* caller layers the user's field edits on top with a normal series update.
|
||||
*/
|
||||
internal fun buildMovedMasterValues(
|
||||
snapshot: MasterEventSnapshot,
|
||||
targetCalendarId: Long,
|
||||
uid: String,
|
||||
): Map<String, Any?> = buildMap {
|
||||
put(CalendarContract.Events.CALENDAR_ID, targetCalendarId)
|
||||
put(CalendarContract.Events.UID_2445, uid)
|
||||
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.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
|
||||
if (snapshot.rrule != null) {
|
||||
put(CalendarContract.Events.RRULE, snapshot.rrule)
|
||||
snapshot.rdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.RDATE, it) }
|
||||
snapshot.exdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.EXDATE, it) }
|
||||
put(CalendarContract.Events.DURATION, snapshot.movedDuration())
|
||||
} else {
|
||||
snapshot.dtEndMillis?.let { put(CalendarContract.Events.DTEND, it) }
|
||||
}
|
||||
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
||||
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
||||
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
||||
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
|
||||
}
|
||||
|
||||
/** The recurring copy's DURATION: its own if present, else derived from DTEND. */
|
||||
private fun MasterEventSnapshot.movedDuration(): String = duration?.takeIf { it.isNotBlank() }
|
||||
?: rfc2445Duration((dtEndMillis ?: dtStartMillis) - dtStartMillis, isAllDay)
|
||||
|
||||
/**
|
||||
* Raw provider snapshot of one exception row of a recurring series (a modified
|
||||
* or cancelled occurrence, `ORIGINAL_ID` = the series). [originalInstanceMillis]
|
||||
* ties it to the occurrence it overrides; a [isCancelled] row only needs that.
|
||||
*/
|
||||
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
|
||||
* provider clone the series row and cancel exactly this one instance.
|
||||
*
|
||||
* As with [buildOccurrenceExceptionValues], the occurrence must be anchored with
|
||||
* DTSTART + DURATION so the provider derives a single instance and clears the
|
||||
* inherited RRULE. A STATUS-only cancel skips that: the clone keeps the RRULE, so
|
||||
* the *whole series* is cancelled and every other occurrence disappears
|
||||
* (Codeberg #47). The occurrence's length/zone come straight from the series row
|
||||
* — cancelling never changes them.
|
||||
*/
|
||||
internal fun buildOccurrenceCancelValues(
|
||||
originalInstanceMillis: Long,
|
||||
dtStartMillis: Long,
|
||||
duration: String?,
|
||||
timezone: String?,
|
||||
allDay: Int,
|
||||
): Map<String, Any?> = buildMap {
|
||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis)
|
||||
put(CalendarContract.Events.DTSTART, dtStartMillis)
|
||||
put(CalendarContract.Events.DURATION, duration)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, timezone)
|
||||
put(CalendarContract.Events.ALL_DAY, allDay)
|
||||
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||
}
|
||||
|
||||
/**
|
||||
* The master-row columns that drop the occurrence at [occurrenceMillis] from a
|
||||
* series by adding it to `EXDATE` — the path for events that have **no
|
||||
* `_sync_id`** (a local calendar, or a synced event not yet pushed).
|
||||
*
|
||||
* A cancelled exception row (see [buildOccurrenceCancelValues]) only attaches to
|
||||
* its parent through `ORIGINAL_SYNC_ID`. Without a `_sync_id` the link never
|
||||
* forms, and the provider's expansion of the *parent* collapses — every other
|
||||
* occurrence disappears (Codeberg #47, reproduced on a local calendar). EXDATE
|
||||
* needs no link, and is the canonical iCalendar way to drop an occurrence, so a
|
||||
* sync adapter carries it upstream unchanged if the calendar later syncs.
|
||||
*
|
||||
* The whole time/recurrence set is rewritten alongside it on purpose. The
|
||||
* provider does **not** treat an EXDATE-only update as a recurrence change: it
|
||||
* leaves the expanded `Instances` rows untouched, so the occurrence stays visible
|
||||
* (and, symmetrically, un-excluding one leaves it hidden). Writing DTSTART with
|
||||
* it forces the re-expansion — but DTSTART *alone* makes the provider recompute
|
||||
* `lastDate` as if the event were a single instance, collapsing the series to its
|
||||
* first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
|
||||
* re-expands it correctly. All observed on a Pixel; see the #47 notes.
|
||||
*
|
||||
* EXDATE is a comma-separated list, so an existing one is appended to (a repeat
|
||||
* of the same occurrence is folded away). All-day series take the `VALUE=DATE`
|
||||
* form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`).
|
||||
*/
|
||||
internal fun buildOccurrenceExdateValues(
|
||||
existingExdate: String?,
|
||||
occurrenceMillis: Long,
|
||||
dtStartMillis: Long,
|
||||
rrule: String?,
|
||||
duration: String?,
|
||||
timezone: String?,
|
||||
allDay: Int,
|
||||
): Map<String, Any?> {
|
||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
|
||||
val existing = existingExdate?.split(',')
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
.orEmpty()
|
||||
val merged = (existing + stamp).distinct().joinToString(",")
|
||||
return mapOf(
|
||||
CalendarContract.Events.EXDATE to merged,
|
||||
CalendarContract.Events.DTSTART to dtStartMillis,
|
||||
CalendarContract.Events.RRULE to rrule,
|
||||
CalendarContract.Events.DURATION to duration,
|
||||
CalendarContract.Events.EVENT_TIMEZONE to timezone,
|
||||
CalendarContract.Events.ALL_DAY to allDay,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
|
||||
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
|
||||
* reads off the UTC calendar day.
|
||||
*/
|
||||
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String {
|
||||
val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC)
|
||||
return if (isAllDay) {
|
||||
"%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth)
|
||||
} else {
|
||||
"%04d%02d%02dT%02d%02d%02dZ".format(
|
||||
utc.year, utc.monthValue, utc.dayOfMonth,
|
||||
utc.hour, utc.minute, utc.second,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ internal object CalendarProjection {
|
||||
// uses to recognise its own managed calendars, independent of any
|
||||
// stored preference id (which a backup restore / data wipe can lose).
|
||||
MANAGED_MARKER_COLUMN,
|
||||
CalendarContract.Calendars.SYNC_EVENTS,
|
||||
)
|
||||
|
||||
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
||||
@@ -36,6 +37,7 @@ internal object CalendarProjection {
|
||||
const val IDX_ACCESS_LEVEL = 6
|
||||
const val IDX_DESCRIPTION = 7
|
||||
const val IDX_MANAGED_MARKER = 8
|
||||
const val IDX_SYNC_EVENTS = 9
|
||||
}
|
||||
|
||||
internal object InstanceProjection {
|
||||
@@ -84,6 +86,9 @@ internal object EventDetailProjection {
|
||||
CalendarContract.Events.EVENT_TIMEZONE,
|
||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||
CalendarContract.Events.EVENT_COLOR_KEY,
|
||||
// Recurring rows carry DURATION instead of DTEND; the detail screen
|
||||
// needs it to render a series opened without a named occurrence.
|
||||
CalendarContract.Events.DURATION,
|
||||
)
|
||||
|
||||
const val IDX_EVENT_ID = 0
|
||||
@@ -104,6 +109,7 @@ internal object EventDetailProjection {
|
||||
const val IDX_EVENT_TIMEZONE = 15
|
||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||
const val IDX_EVENT_COLOR_KEY = 17
|
||||
const val IDX_DURATION = 18
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,6 +192,86 @@ internal object SearchProjection {
|
||||
const val IDX_RDATE = 11
|
||||
}
|
||||
|
||||
/**
|
||||
* The master/one-off Events row of an event about to be moved to another
|
||||
* calendar, read for a verbatim re-insert (see [MasterEventSnapshot]). Carries
|
||||
* the full recurrence skeleton (RRULE/DURATION, RDATE/EXDATE) so the moved copy
|
||||
* generates the same instances, and `UID_2445` so identity survives the move.
|
||||
*/
|
||||
internal object MoveMasterProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Events.UID_2445,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.DTEND,
|
||||
CalendarContract.Events.DURATION,
|
||||
CalendarContract.Events.RRULE,
|
||||
CalendarContract.Events.RDATE,
|
||||
CalendarContract.Events.EXDATE,
|
||||
CalendarContract.Events.EVENT_TIMEZONE,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events.AVAILABILITY,
|
||||
CalendarContract.Events.ACCESS_LEVEL,
|
||||
CalendarContract.Events.STATUS,
|
||||
CalendarContract.Events.EVENT_LOCATION,
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
)
|
||||
|
||||
const val IDX_UID = 0
|
||||
const val IDX_TITLE = 1
|
||||
const val IDX_DTSTART = 2
|
||||
const val IDX_DTEND = 3
|
||||
const val IDX_DURATION = 4
|
||||
const val IDX_RRULE = 5
|
||||
const val IDX_RDATE = 6
|
||||
const val IDX_EXDATE = 7
|
||||
const val IDX_EVENT_TIMEZONE = 8
|
||||
const val IDX_ALL_DAY = 9
|
||||
const val IDX_AVAILABILITY = 10
|
||||
const val IDX_ACCESS_LEVEL = 11
|
||||
const val IDX_STATUS = 12
|
||||
const val IDX_LOCATION = 13
|
||||
const val IDX_DESCRIPTION = 14
|
||||
}
|
||||
|
||||
/**
|
||||
* The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to
|
||||
* 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 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_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 {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
fun Long.toKotlinInstantFromEpochMillis(): Instant = Instant.fromEpochMilliseconds(this)
|
||||
|
||||
fun Instant.toEpochMillis(): Long = toEpochMilliseconds()
|
||||
@@ -4,7 +4,7 @@ import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
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
|
||||
@@ -111,13 +111,13 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
* all-day override so new events keep matching. No-op if the calendar for
|
||||
* [type] doesn't exist yet.
|
||||
*/
|
||||
suspend fun applyReminders(type: SpecialDateType, override: CalendarReminderOverride) {
|
||||
suspend fun applyReminders(type: SpecialDateType, override: ReminderOverride) {
|
||||
val calendarId = prefs.specialDatesCalendars.first()[type] ?: return
|
||||
prefs.setCalendarAllDayReminderOverride(calendarId, override)
|
||||
val minutes = when (override) {
|
||||
CalendarReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
||||
CalendarReminderOverride.None -> emptyList()
|
||||
is CalendarReminderOverride.Minutes -> override.minutes
|
||||
ReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
||||
ReminderOverride.None -> emptyList()
|
||||
is ReminderOverride.Minutes -> override.minutes
|
||||
}
|
||||
calendars.applyManagedCalendarReminders(
|
||||
calendarId = calendarId,
|
||||
@@ -169,7 +169,7 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
|
||||
prefs.setCalendarAllDayReminderOverride(
|
||||
id,
|
||||
CalendarReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
||||
ReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
||||
)
|
||||
}
|
||||
return id
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.crash
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.Build
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Privacy-respecting crash capture (prod-readiness item 10). On an uncaught
|
||||
* exception it writes a self-contained report to the app's private storage and
|
||||
* then chains to the platform's default handler, so the process still dies
|
||||
* normally (and the OS shows its own "stopped" dialog). Nothing is uploaded —
|
||||
* the app holds no `INTERNET` permission. The user submits the report later,
|
||||
* by hand, as a Gitea issue (see the ui/crash surfaces).
|
||||
*
|
||||
* The report is built from a fixed [CrashContext] allowlist — app/Android/device
|
||||
* version, locale, time, and the stack trace — and **nothing else**: no device
|
||||
* identifiers, no account names, no calendar/event content, no logcat. The user
|
||||
* is always shown the full text before it leaves the device.
|
||||
*/
|
||||
object CrashReporter {
|
||||
|
||||
/**
|
||||
* Install the handler. Called first thing in `CalendulaApp.onCreate()` so it
|
||||
* also catches crashes during startup. The handler swallows nothing — it
|
||||
* persists, then delegates to the previously-registered handler.
|
||||
*/
|
||||
fun install(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
// Capturing must never mask the original crash, so guard every step.
|
||||
runCatching {
|
||||
val now = System.currentTimeMillis()
|
||||
writeReport(appContext, buildCrashReport(CrashContext.from(appContext), throwable, now))
|
||||
recordCrashTime(appContext, now)
|
||||
}
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
/** The persisted report from the last crash, or null if there is none. */
|
||||
fun pendingReport(context: Context): String? {
|
||||
val file = reportFile(context)
|
||||
return if (file.exists()) runCatching { file.readText() }.getOrNull()?.takeIf { it.isNotBlank() } else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to surface the report unprompted (on the next launch): a report
|
||||
* exists and the user hasn't already waved this one away. Settings reaches
|
||||
* the report via [pendingReport] regardless, so "Not now" only stops the
|
||||
* auto-prompt — it doesn't discard the report.
|
||||
*/
|
||||
fun shouldPrompt(context: Context): Boolean =
|
||||
reportFile(context).exists() && !dismissedFile(context).exists()
|
||||
|
||||
/** Stop auto-prompting for the current report without discarding it. */
|
||||
fun dismissPrompt(context: Context) {
|
||||
runCatching { dismissedFile(context).apply { parentFile?.mkdirs() }.writeText("") }
|
||||
}
|
||||
|
||||
/** Drop the persisted report once the user has reported it (or from Settings). */
|
||||
fun clearReport(context: Context) {
|
||||
runCatching { reportFile(context).delete() }
|
||||
runCatching { dismissedFile(context).delete() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app appears to be in a startup crash-loop: at least
|
||||
* [LOOP_THRESHOLD] crashes inside [LOOP_WINDOW_MS]. In that case the main UI
|
||||
* can't be trusted to start, so the caller routes straight to the standalone
|
||||
* report screen instead of re-entering the crashing graph.
|
||||
*/
|
||||
fun isCrashLoop(context: Context): Boolean {
|
||||
val times = readCrashTimes(context)
|
||||
if (times.size < LOOP_THRESHOLD) return false
|
||||
val recent = times.sortedDescending()
|
||||
return recent[0] - recent[LOOP_THRESHOLD - 1] <= LOOP_WINDOW_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the app as having started successfully, resetting the loop counter so
|
||||
* an ordinary single crash much later never trips loop detection. The
|
||||
* pending report itself is kept — only the timing trail is cleared.
|
||||
*/
|
||||
fun markHealthy(context: Context) {
|
||||
runCatching { timesFile(context).delete() }
|
||||
}
|
||||
|
||||
// --- persistence -------------------------------------------------------
|
||||
|
||||
private fun writeReport(context: Context, report: String) {
|
||||
val file = reportFile(context).apply { parentFile?.mkdirs() }
|
||||
file.writeText(report.take(MAX_REPORT_CHARS))
|
||||
// A fresh crash should prompt again, even if the previous one was waved away.
|
||||
runCatching { dismissedFile(context).delete() }
|
||||
}
|
||||
|
||||
private fun recordCrashTime(context: Context, nowMillis: Long) {
|
||||
val kept = (readCrashTimes(context) + nowMillis).takeLast(MAX_TIMES)
|
||||
timesFile(context).apply { parentFile?.mkdirs() }
|
||||
.writeText(kept.joinToString("\n"))
|
||||
}
|
||||
|
||||
private fun readCrashTimes(context: Context): List<Long> {
|
||||
val file = timesFile(context)
|
||||
if (!file.exists()) return emptyList()
|
||||
return runCatching { file.readLines().mapNotNull { it.trim().toLongOrNull() } }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun crashDir(context: Context) = File(context.filesDir, CRASH_DIR)
|
||||
private fun reportFile(context: Context) = File(crashDir(context), REPORT_FILE)
|
||||
private fun timesFile(context: Context) = File(crashDir(context), TIMES_FILE)
|
||||
private fun dismissedFile(context: Context) = File(crashDir(context), DISMISSED_FILE)
|
||||
|
||||
private const val CRASH_DIR = "crash"
|
||||
private const val REPORT_FILE = "last_crash.txt"
|
||||
private const val TIMES_FILE = "crash_times.txt"
|
||||
private const val DISMISSED_FILE = "dismissed"
|
||||
private const val MAX_TIMES = 5
|
||||
private const val MAX_REPORT_CHARS = 64 * 1024
|
||||
private const val LOOP_THRESHOLD = 2
|
||||
private const val LOOP_WINDOW_MS = 10_000L
|
||||
}
|
||||
|
||||
/**
|
||||
* The allowlist of non-personal facts that go into a crash report. Built from
|
||||
* [Build] and the app's own [PackageInfo]; deliberately holds no identifiers.
|
||||
*/
|
||||
data class CrashContext(
|
||||
val appVersionName: String,
|
||||
val appVersionCode: Long,
|
||||
val sdkInt: Int,
|
||||
val androidRelease: String,
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val locale: String,
|
||||
) {
|
||||
companion object {
|
||||
fun from(context: Context): CrashContext {
|
||||
val pkg = runCatching {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
}.getOrNull()
|
||||
return CrashContext(
|
||||
appVersionName = pkg?.versionName ?: "?",
|
||||
appVersionCode = pkg?.let { PackageInfoCompat.getLongVersionCode(it) } ?: 0L,
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
androidRelease = Build.VERSION.RELEASE ?: "?",
|
||||
manufacturer = Build.MANUFACTURER ?: "?",
|
||||
model = Build.MODEL ?: "?",
|
||||
locale = Locale.getDefault().toLanguageTag(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a crash report from the [ctx] allowlist, the [throwable]'s full stack
|
||||
* trace, and the crash [nowMillis]. Pure (no Android, no I/O) so it is unit
|
||||
* tested. The leading marker doubles as the file's sanity check in
|
||||
* [CrashReporter.pendingReport].
|
||||
*/
|
||||
fun buildCrashReport(ctx: CrashContext, throwable: Throwable, nowMillis: Long): String {
|
||||
val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
|
||||
val time = runCatching {
|
||||
Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()).format(TIME_FORMAT)
|
||||
}.getOrDefault(nowMillis.toString())
|
||||
return buildString {
|
||||
appendLine("Calendula crash report")
|
||||
appendLine("App version: ${ctx.appVersionName} (${ctx.appVersionCode})")
|
||||
appendLine("Android: ${ctx.androidRelease} (API ${ctx.sdkInt})")
|
||||
appendLine("Device: ${ctx.manufacturer} ${ctx.model}")
|
||||
appendLine("Locale: ${ctx.locale}")
|
||||
appendLine("Time: $time")
|
||||
appendLine()
|
||||
appendLine("Stack trace:")
|
||||
append(trace)
|
||||
}
|
||||
}
|
||||
|
||||
private val TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
@@ -18,8 +18,8 @@ import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataS
|
||||
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import javax.inject.Singleton
|
||||
@@ -46,9 +46,9 @@ abstract class DataBindModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindReminderAlertStore(
|
||||
impl: AndroidReminderAlertStore,
|
||||
): ReminderAlertStore
|
||||
abstract fun bindReminderInstanceSource(
|
||||
impl: ProviderReminderInstanceSource,
|
||||
): ReminderInstanceSource
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
package de.jeanlucmakiola.calendula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
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
|
||||
|
||||
/**
|
||||
* App-side preference for "calendars the user has hidden in this app",
|
||||
* separate from the system's per-calendar VISIBLE flag.
|
||||
* App-side calendar preferences. [hiddenCalendarIds] is the drawer's filter
|
||||
* sheet — a purely in-app declutter that deliberately does *not* suppress
|
||||
* reminders. Switching a calendar off entirely is the system's
|
||||
* `Calendars.VISIBLE` flag, written straight to the provider (#75);
|
||||
* [pendingDisabledCalendarIds] only holds those switch-offs the app has not been
|
||||
* allowed to write yet.
|
||||
*
|
||||
* Persisted as a comma-separated string of Long ids; non-numeric tokens are
|
||||
* silently dropped (defensive — see CalendarPrefsTest).
|
||||
@@ -22,46 +29,56 @@ class CalendarPrefs @Inject constructor(
|
||||
private val store: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
val hiddenCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
||||
prefs[HIDDEN_IDS_KEY].orEmpty()
|
||||
.split(',')
|
||||
.mapNotNull { it.trim().toLongOrNull() }
|
||||
.toSet()
|
||||
}
|
||||
// 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>) {
|
||||
store.edit { prefs -> prefs.writeIds(HIDDEN_IDS_KEY, ids) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch-offs the provider does not know about yet, because writing
|
||||
* `Calendars.VISIBLE` needs `WRITE_CALENDAR` (#75). Also inherits the
|
||||
* retired app-local model's set, from the same key.
|
||||
*
|
||||
* Honoured as a display and reminder filter while non-empty, but not a
|
||||
* 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>> = store.data
|
||||
.map { prefs -> prefs[DISABLED_IDS_KEY].parseIds() }
|
||||
.distinctUntilChanged()
|
||||
|
||||
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>) =
|
||||
editPendingDisabled { it - ids.toSet() }
|
||||
|
||||
private suspend fun editPendingDisabled(transform: (Set<Long>) -> Set<Long>) {
|
||||
store.edit { prefs ->
|
||||
if (ids.isEmpty()) {
|
||||
prefs.remove(HIDDEN_IDS_KEY)
|
||||
} else {
|
||||
prefs[HIDDEN_IDS_KEY] = ids.sorted().joinToString(",")
|
||||
}
|
||||
prefs.writeIds(DISABLED_IDS_KEY, transform(prefs[DISABLED_IDS_KEY].parseIds()))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* App-side preference for "calendars the user has disabled in this app" — a
|
||||
* heavier level than [hiddenCalendarIds]. A disabled calendar is removed from
|
||||
* every surface (drawer filter, event-form picker, import picker) and its
|
||||
* events never appear; it stays listed only in Settings → Calendars so it can
|
||||
* be re-enabled. Stored exactly like the hidden set; never touches the
|
||||
* system's VISIBLE/SYNC_EVENTS flags, so other calendar apps are unaffected.
|
||||
* Whether the one-time "visibility follows this device" notice is still
|
||||
* owed. Null until the reconciler has evaluated it (which needs the calendar
|
||||
* permission), false once it has been shown or was never needed.
|
||||
*/
|
||||
val disabledCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
||||
prefs[DISABLED_IDS_KEY].orEmpty()
|
||||
.split(',')
|
||||
.mapNotNull { it.trim().toLongOrNull() }
|
||||
.toSet()
|
||||
val visibilityNoticePending: Flow<Boolean?> = store.data.map { prefs ->
|
||||
prefs[VISIBILITY_NOTICE_KEY]
|
||||
}
|
||||
|
||||
suspend fun setDisabledCalendarIds(ids: Set<Long>) {
|
||||
store.edit { prefs ->
|
||||
if (ids.isEmpty()) {
|
||||
prefs.remove(DISABLED_IDS_KEY)
|
||||
} else {
|
||||
prefs[DISABLED_IDS_KEY] = ids.sorted().joinToString(",")
|
||||
}
|
||||
}
|
||||
suspend fun setVisibilityNoticePending(pending: Boolean) {
|
||||
store.edit { prefs -> prefs[VISIBILITY_NOTICE_KEY] = pending }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,6 +96,16 @@ class CalendarPrefs @Inject constructor(
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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(","))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.jeanlucmakiola.calendula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
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
|
||||
|
||||
/**
|
||||
* How far reminder delivery has got — the one number replacing the provider's
|
||||
* `CalendarAlerts.STATE` (#75). A scan posts the reminders falling after this
|
||||
* watermark and up to now, then moves it to now, so a scan running twice cannot
|
||||
* post twice while a late one still catches up.
|
||||
*
|
||||
* Unset means "never scanned", not zero: the first scan after an install would
|
||||
* otherwise treat every reminder since the epoch as overdue.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderStatePrefs @Inject constructor(
|
||||
private val store: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
/** The watermark, or `null` before the first scan has ever run. */
|
||||
suspend fun lastScanMillis(): Long? = store.data.map { it[LAST_SCAN_KEY] }.first()
|
||||
|
||||
suspend fun setLastScanMillis(millis: Long) {
|
||||
store.edit { prefs -> prefs[LAST_SCAN_KEY] = millis }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val LAST_SCAN_KEY = longPreferencesKey("reminder_last_scan_millis")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.normalizeReminders
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
@@ -16,9 +21,16 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import java.time.ZoneId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import java.time.temporal.WeekFields
|
||||
import java.util.Locale
|
||||
@@ -76,6 +88,24 @@ fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
||||
WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved first day of the week as a hot [StateFlow] — the one shape every
|
||||
* screen that orders or lays out weekdays should use (the month grid, the agenda
|
||||
* "this week" range, the recurrence weekday toggles). Sharing it keeps the
|
||||
* initial-frame value identical everywhere; hand-rolled copies had drifted onto
|
||||
* different `initialValue`s, so two surfaces could disagree for a frame.
|
||||
*/
|
||||
fun SettingsPrefs.firstDayOfWeek(scope: CoroutineScope): StateFlow<DayOfWeek> =
|
||||
weekStart
|
||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
||||
.stateIn(
|
||||
scope = scope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
// Seed with the locale convention rather than a hardcoded weekday, so
|
||||
// the first frame is already right for everyone still on Auto.
|
||||
initialValue = WeekStartPref.Auto.resolveFirstDay(Locale.getDefault()),
|
||||
)
|
||||
|
||||
/**
|
||||
* Display settings (M4) persisted app-side: theme override, Material You
|
||||
* dynamic colour, and week start. Language is handled separately through
|
||||
@@ -109,6 +139,21 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether raw provider colours are softened toward theme-fitting pastels
|
||||
* before display (issue #36). Defaults to ON — the historical look, which
|
||||
* caps harsh sync colours and pins brightness so entries read on both
|
||||
* themes. Turning it off paints the calendar/event colour exactly as the
|
||||
* sync source (DAVx5/CalDAV) publishes it.
|
||||
*/
|
||||
val softenCalendarColors: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[SOFTEN_COLORS_KEY] ?: true
|
||||
}
|
||||
|
||||
suspend fun setSoftenCalendarColors(enabled: Boolean) {
|
||||
store.edit { it[SOFTEN_COLORS_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom-font tokens per Material typeface role (issue #19). Stored as opaque
|
||||
* strings — "system", "custom", or a bundled font's token — resolved to a
|
||||
@@ -200,6 +245,46 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the Month grid shows the calendar-week (ISO) number in a left
|
||||
* gutter (#25). Defaults to OFF — users opt in, since it narrows the day
|
||||
* cells slightly. The Week view shows its number unconditionally.
|
||||
*/
|
||||
val showWeekNumbers: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[SHOW_WEEK_NUMBERS_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setShowWeekNumbers(enabled: Boolean) {
|
||||
store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How the Month view lays itself out (#38, #53). Defaults to [MonthViewStyle.Paged]
|
||||
* — the historical behaviour, so existing installs see no change until they opt in.
|
||||
*/
|
||||
val monthViewStyle: Flow<MonthViewStyle> = store.data.map { prefs ->
|
||||
prefs[MONTH_VIEW_STYLE_KEY].toEnum(MonthViewStyle.Paged)
|
||||
}
|
||||
|
||||
suspend fun setMonthViewStyle(style: MonthViewStyle) {
|
||||
store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
||||
* historical layout, where it's an extended FAB that fades in above the "+"
|
||||
* only while the view is off today. Turning it ON moves it to a persistent
|
||||
* icon button in each calendar view's top bar (always shown, on today or
|
||||
* not) and drops the FAB pill.
|
||||
*/
|
||||
val todayButtonInToolbar: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[TODAY_BUTTON_IN_TOOLBAR_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setTodayButtonInToolbar(enabled: Boolean) {
|
||||
store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||
@@ -222,6 +307,23 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The size step the agenda widget draws its text at (#51). Defaults to
|
||||
* [WidgetSize.SMALL], which reproduces its original metrics, so an existing
|
||||
* widget is unchanged until its owner turns the size up.
|
||||
*
|
||||
* This replaced deriving a size tier from the widget's measured size, which
|
||||
* the launcher does not report reliably. The month widget takes no size
|
||||
* setting — it divides the width it is given by seven (#103).
|
||||
*/
|
||||
val widgetSize: Flow<WidgetSize> = store.data.map { prefs ->
|
||||
prefs[WIDGET_SIZE_KEY].toEnum(WidgetSize.SMALL)
|
||||
}
|
||||
|
||||
suspend fun setWidgetSize(size: WidgetSize) {
|
||||
store.edit { it[WIDGET_SIZE_KEY] = size.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agenda shows its top range bar — the "showing …" header and
|
||||
* the session range switcher (v2.11). Default ON.
|
||||
@@ -234,6 +336,20 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agenda (both the in-app screen and the home-screen widget)
|
||||
* always anchors today at the top with a "nothing left today" placeholder,
|
||||
* even once today has no remaining events (issue #35). Default ON — makes
|
||||
* today's events easy to tell apart from a future day's at a glance.
|
||||
*/
|
||||
val agendaShowToday: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[AGENDA_SHOW_TODAY_KEY] ?: true
|
||||
}
|
||||
|
||||
suspend fun setAgendaShowToday(enabled: Boolean) {
|
||||
store.edit { it[AGENDA_SHOW_TODAY_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
|
||||
* the historical hard-coded startup view — so existing users see no change
|
||||
@@ -307,6 +423,26 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zones the user picked recently, most recent first — the timezone picker's
|
||||
* default list, since scrolling ~600 zones to re-find the two you actually
|
||||
* use is the whole problem. Stored comma-joined (IANA ids never contain a
|
||||
* comma); unparseable ids are dropped on read, so a zone the tz database
|
||||
* later retires can't wedge the list.
|
||||
*/
|
||||
val recentTimeZones: Flow<List<String>> = store.data.map { prefs ->
|
||||
parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY])
|
||||
}
|
||||
|
||||
suspend fun addRecentTimeZone(zoneId: String) {
|
||||
store.edit { prefs ->
|
||||
val updated = (listOf(zoneId) + parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY]))
|
||||
.distinct()
|
||||
.take(MAX_RECENT_TIME_ZONES)
|
||||
prefs[RECENT_TIMEZONES_KEY] = updated.joinToString(",")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opening the new-event form focuses the title field and raises the
|
||||
* keyboard straight away (issue #10). Default ON — a new event almost always
|
||||
@@ -436,14 +572,14 @@ class SettingsPrefs @Inject constructor(
|
||||
* (All-day events ignore this and use [defaultAllDayReminderMinutes].)
|
||||
*/
|
||||
val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||
reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||
}
|
||||
|
||||
suspend fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||
suspend fun setCalendarReminderOverride(calendarId: Long, override: ReminderOverride) {
|
||||
store.edit { prefs ->
|
||||
val current = parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||
current.applyOverride(calendarId, override)
|
||||
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
||||
val current = reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||
current.applyReminderOverride(calendarId, override)
|
||||
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,18 +589,18 @@ class SettingsPrefs @Inject constructor(
|
||||
* inherit the global all-day default; present null = no reminder).
|
||||
*/
|
||||
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||
reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||
}
|
||||
|
||||
suspend fun setCalendarAllDayReminderOverride(
|
||||
calendarId: Long,
|
||||
override: CalendarReminderOverride,
|
||||
override: ReminderOverride,
|
||||
) {
|
||||
store.edit { prefs ->
|
||||
val current =
|
||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||
current.applyOverride(calendarId, override)
|
||||
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
||||
reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||
current.applyReminderOverride(calendarId, override)
|
||||
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,6 +739,13 @@ class SettingsPrefs @Inject constructor(
|
||||
private fun titleTemplateKey(type: SpecialDateType) =
|
||||
stringPreferencesKey("special_dates_title_${type.name}")
|
||||
|
||||
private fun parseRecentTimeZones(stored: String?): List<String> =
|
||||
stored?.split(',').orEmpty()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && runCatching { ZoneId.of(it) }.isSuccess }
|
||||
.distinct()
|
||||
.take(MAX_RECENT_TIME_ZONES)
|
||||
|
||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||
null -> DEFAULT_FORM_FIELDS
|
||||
else -> stored.split(',')
|
||||
@@ -647,6 +790,7 @@ class SettingsPrefs @Inject constructor(
|
||||
companion object {
|
||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||
internal val SOFTEN_COLORS_KEY = booleanPreferencesKey("soften_calendar_colors")
|
||||
internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font")
|
||||
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
|
||||
internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp")
|
||||
@@ -655,10 +799,16 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||
internal val WIDGET_SIZE_KEY = stringPreferencesKey("widget_size")
|
||||
internal val AGENDA_SHOW_TODAY_KEY =
|
||||
booleanPreferencesKey("agenda_show_today")
|
||||
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
||||
internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style")
|
||||
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||
@@ -685,6 +835,10 @@ class SettingsPrefs @Inject constructor(
|
||||
stringPreferencesKey("per_calendar_allday_reminder_override")
|
||||
internal val DEFAULT_FORM_FIELDS =
|
||||
setOf(EventFormField.Location, EventFormField.Description)
|
||||
internal val RECENT_TIMEZONES_KEY = stringPreferencesKey("recent_time_zones")
|
||||
|
||||
/** Enough to cover the zones a user actually recurs to, without a wall of rows. */
|
||||
internal const val MAX_RECENT_TIME_ZONES = 5
|
||||
internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
|
||||
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
|
||||
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
|
||||
@@ -727,28 +881,6 @@ data class BackupStatus(
|
||||
val consecutiveFailures: Int,
|
||||
)
|
||||
|
||||
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
|
||||
sealed interface CalendarReminderOverride {
|
||||
/** No override — the calendar uses the global default. */
|
||||
data object Inherit : CalendarReminderOverride
|
||||
/** Explicit "no reminder" for this calendar, regardless of the global default. */
|
||||
data object None : CalendarReminderOverride
|
||||
/** Specific lead times in minutes before the event start (non-empty). */
|
||||
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored override for [calendarId] in an override map, as a
|
||||
* [CalendarReminderOverride] (absent → [CalendarReminderOverride.Inherit],
|
||||
* empty → [CalendarReminderOverride.None]) — the inverse of how
|
||||
* [SettingsPrefs.setCalendarReminderOverride] stores a choice.
|
||||
*/
|
||||
fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
||||
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
||||
getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
|
||||
else -> CalendarReminderOverride.Minutes(getValue(calendarId))
|
||||
}
|
||||
|
||||
/**
|
||||
* The lead times to prefill on a new event: the matching per-calendar override
|
||||
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||
@@ -773,21 +905,6 @@ fun resolveDefaultReminder(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the
|
||||
* key; [None] and an empty [Minutes] both store the empty list).
|
||||
*/
|
||||
private fun MutableMap<Long, List<Int>>.applyOverride(
|
||||
calendarId: Long,
|
||||
override: CalendarReminderOverride,
|
||||
) {
|
||||
when (override) {
|
||||
CalendarReminderOverride.Inherit -> remove(calendarId)
|
||||
CalendarReminderOverride.None -> put(calendarId, emptyList())
|
||||
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders())
|
||||
}
|
||||
}
|
||||
|
||||
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
||||
private const val WEEK_START_AUTO = "AUTO"
|
||||
|
||||
@@ -812,8 +929,14 @@ private const val ENTRY_SEP = ";"
|
||||
private const val KEY_VALUE_SEP = "="
|
||||
private const val LIST_SEP = ","
|
||||
|
||||
/** Distinct, ascending lead times — the canonical form stored and resolved. */
|
||||
private fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
|
||||
/**
|
||||
* The per-calendar override map codec, in Calendula's stored dialect
|
||||
* (`id=minutes` entries joined by `;`, minutes comma-joined, `none` for an
|
||||
* explicit no-reminder). The model and codec live in floret-kit; the dialect
|
||||
* (fixed at release) stays here.
|
||||
*/
|
||||
private val reminderOverrideCodec =
|
||||
ReminderOverrideCodec(entrySep = ENTRY_SEP, keyValueSep = KEY_VALUE_SEP, listSep = LIST_SEP, noneToken = NONE)
|
||||
|
||||
/**
|
||||
* Parse a stored reminder value into lead times. `null`/empty/"none" → empty
|
||||
@@ -829,25 +952,6 @@ private fun String?.toReminderList(): List<Int> = when {
|
||||
private fun List<Int>.toStoredReminders(): String =
|
||||
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
||||
|
||||
private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
|
||||
if (stored.isNullOrBlank()) return emptyMap()
|
||||
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
||||
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
||||
// Only the deliberate "none" sentinel means an explicit no-reminder
|
||||
// override (empty list); a non-sentinel value that parses to no valid
|
||||
// minutes is garbage and drops the entry, so the calendar inherits the
|
||||
// global default rather than silently reading as "no reminder".
|
||||
when (val value = parts[1]) {
|
||||
NONE -> id to emptyList()
|
||||
else -> value.toReminderList().takeIf { it.isNotEmpty() }?.let { id to it }
|
||||
?: return@mapNotNull null
|
||||
}
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String =
|
||||
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" }
|
||||
|
||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.Manifest
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.CalendarContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* True when [this] alert belongs to a calendar the user disabled in-app, so its
|
||||
* reminder must be suppressed (mirroring the event filtering in
|
||||
* CalendarRepositoryImpl). Alerts whose calendar is unknown (id 0L — e.g. a
|
||||
* pre-upgrade snooze PendingIntent minted before EXTRA_CALENDAR_ID existed) are
|
||||
* never treated as disabled. This is the one predicate the disabled-calendar
|
||||
* gate is built from: [postableAlerts] here and the choke point in
|
||||
* [ReminderNotifier.post] both use it.
|
||||
*/
|
||||
internal fun ReminderAlert.isForDisabledCalendar(disabledCalendarIds: Set<Long>): Boolean =
|
||||
calendarId != 0L && calendarId in disabledCalendarIds
|
||||
|
||||
/**
|
||||
* The due alerts that should actually surface as notifications: everything
|
||||
* except alerts whose calendar the user has disabled in-app. The caller still
|
||||
* marks the full due set fired, so suppressed alerts are not re-broadcast by the
|
||||
* provider.
|
||||
*/
|
||||
internal fun postableAlerts(
|
||||
due: List<ReminderAlert>,
|
||||
disabledCalendarIds: Set<Long>,
|
||||
): List<ReminderAlert> = due.filterNot { it.isForDisabledCalendar(disabledCalendarIds) }
|
||||
|
||||
/**
|
||||
* Becomes the app that turns the calendar provider's reminder alarms into
|
||||
* visible notifications (the Etar model — the provider broadcasts
|
||||
* `EVENT_REMINDER` at reminder time but posts nothing itself).
|
||||
*
|
||||
* The broadcast's data URI only carries the alarm time, so it is ignored:
|
||||
* we query every still-scheduled, due `CalendarAlerts` row ourselves, post
|
||||
* them, and mark them fired. Posting happens before marking — a crash in
|
||||
* between re-posts silently (same tag) rather than losing the reminder.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class EventReminderReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var alertStore: ReminderAlertStore
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
@Inject lateinit var calendarPrefs: CalendarPrefs
|
||||
@Inject lateinit var suppressedStore: SuppressedReminderStore
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||
val readGranted = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (!readGranted || !notifier.canPost()) return
|
||||
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
if (settingsPrefs.remindersEnabled.first()) {
|
||||
val now = System.currentTimeMillis()
|
||||
val due = alertStore.dueAlerts(now)
|
||||
val disabled = calendarPrefs.disabledCalendarIds.first()
|
||||
val postable = postableAlerts(due, disabled)
|
||||
// Suppress reminders for disabled calendars, but still mark
|
||||
// every due alert fired so the provider stops re-broadcasting
|
||||
// the suppressed ones. Stash those suppressed alerts so
|
||||
// re-enabling their calendar can recover them (they would
|
||||
// otherwise stay STATE_FIRED forever with no re-scan).
|
||||
postable.forEach { notifier.post(it) }
|
||||
alertStore.markFired(due.map { it.alertId }, now)
|
||||
suppressedStore.stash(due - postable.toSet(), now)
|
||||
suppressedStore.purgeExpired(now)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.reminders
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -18,12 +19,14 @@ import javax.inject.Inject
|
||||
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
||||
* alarm), so the receiver is not exported.
|
||||
*
|
||||
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
|
||||
* already fired, so nothing re-posts it.
|
||||
* - **Dismiss** just cancels the notification — the scan's watermark has moved
|
||||
* past this reminder, so nothing re-posts it.
|
||||
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
||||
* it after the user's snooze delay.
|
||||
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
|
||||
* or dismiss it again.
|
||||
* or dismiss it again — unless the calendar was switched off during the
|
||||
* snooze, which [ReminderNotifier.post] catches — this alarm is its own
|
||||
* trigger, outside the ordinary scan.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ReminderActionReceiver : BroadcastReceiver() {
|
||||
@@ -73,7 +76,14 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
||||
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
|
||||
|
||||
private const val EXTRA_ALERT_ID = "alert_id"
|
||||
/**
|
||||
* Not handled here — the notification body opens the detail screen
|
||||
* directly. It only claims a slot in [requestCode] so that intent stays
|
||||
* distinct from the three this receiver does handle.
|
||||
*/
|
||||
const val ACTION_OPEN = "de.jeanlucmakiola.calendula.reminders.OPEN"
|
||||
|
||||
private const val EXTRA_ALERT_KEY = "alert_key"
|
||||
private const val EXTRA_EVENT_ID = "event_id"
|
||||
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||
private const val EXTRA_BEGIN = "begin"
|
||||
@@ -82,11 +92,16 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
private const val EXTRA_LOCATION = "location"
|
||||
private const val EXTRA_ALL_DAY = "all_day"
|
||||
|
||||
/** An explicit intent to this receiver carrying [alert] as extras. */
|
||||
/**
|
||||
* An explicit intent to this receiver carrying [alert] as extras. The
|
||||
* data URI duplicates no information but is what keeps two reminders'
|
||||
* `PendingIntent`s apart — `filterEquals` never compares extras.
|
||||
*/
|
||||
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
||||
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||
this.action = action
|
||||
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
||||
data = "calendula://reminder/${alert.key}".toUri()
|
||||
putExtra(EXTRA_ALERT_KEY, alert.key)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||
@@ -97,23 +112,25 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable request code per (alert, action) so the three PendingIntents
|
||||
* of one notification stay distinct and don't clobber each other.
|
||||
* A stable request code per (alert, action), so one notification's
|
||||
* PendingIntents stay distinct. The shift keeps the action slot intact;
|
||||
* the top bits it drops are separated by [intent]'s per-reminder URI.
|
||||
*/
|
||||
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||
val actionOffset = when (action) {
|
||||
ACTION_SNOOZE -> 1
|
||||
ACTION_DISMISS -> 2
|
||||
ACTION_SHOW -> 3
|
||||
ACTION_OPEN -> 4
|
||||
else -> 0
|
||||
}
|
||||
return alert.alertId.toInt() * 8 + actionOffset
|
||||
return (alert.key.toInt() shl 3) + actionOffset
|
||||
}
|
||||
|
||||
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
|
||||
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
|
||||
return ReminderAlert(
|
||||
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
||||
key = intent.getLongExtra(EXTRA_ALERT_KEY, 0L),
|
||||
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
||||
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
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 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
|
||||
* re-arms. Exact, with an inexact allow-while-idle fallback where the OS
|
||||
* withholds the capability (API 31–32 with the permission revoked).
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderAlarmScheduler @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
fun scheduleScan(triggerAtMillis: Long) {
|
||||
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||
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. */
|
||||
fun cancelScan() {
|
||||
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||
alarmManager.cancel(scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
}
|
||||
|
||||
private fun scanPendingIntent(flags: Int): PendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
SCAN_REQUEST_CODE,
|
||||
Intent(context, ReminderScheduleReceiver::class.java)
|
||||
.setAction(ReminderScheduleReceiver.ACTION_SCAN),
|
||||
flags or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
// Fixed: there is only ever one scan alarm, and re-arming must replace it.
|
||||
const val SCAN_REQUEST_CODE = 0x5CA1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
|
||||
|
||||
/**
|
||||
* One reminder as the notification layer needs it: what to show, and the stable
|
||||
* [key] identifying it across a reboot, a re-scan and a reinstall. Derived from
|
||||
* the reminder itself (see [PlannedReminder.key]) — in-house delivery has no
|
||||
* `CalendarAlerts` row to take an id from (#75).
|
||||
*/
|
||||
data class ReminderAlert(
|
||||
val key: Long,
|
||||
val eventId: Long,
|
||||
val calendarId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||
val title: String,
|
||||
val location: String?,
|
||||
val isAllDay: Boolean,
|
||||
)
|
||||
|
||||
fun PlannedReminder.toAlert(): ReminderAlert = ReminderAlert(
|
||||
key = key,
|
||||
eventId = instance.eventId,
|
||||
calendarId = instance.calendarId,
|
||||
beginMillis = instance.beginMillis,
|
||||
endMillis = instance.endMillis,
|
||||
title = instance.title,
|
||||
location = instance.location,
|
||||
isAllDay = instance.isAllDay,
|
||||
)
|
||||
@@ -1,115 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* One due row of the provider's `CalendarAlerts` table (a join with Events).
|
||||
* Stays in the data layer: alerts feed the notification path only and never
|
||||
* reach a screen, so there is no domain model for them.
|
||||
*/
|
||||
data class ReminderAlert(
|
||||
val alertId: Long,
|
||||
val eventId: Long,
|
||||
val calendarId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||
val title: String,
|
||||
val location: String?,
|
||||
val isAllDay: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Seam over the `CalendarAlerts` table so the receiver logic can be exercised
|
||||
* without a ContentResolver. The provider creates these rows itself — only
|
||||
* for `METHOD_ALERT` reminders (verified in AOSP `CalendarAlarmManager`), so
|
||||
* email reminders never show up here.
|
||||
*/
|
||||
interface ReminderAlertStore {
|
||||
|
||||
/** Alerts that are due (`ALARM_TIME` has passed) and still unhandled. */
|
||||
fun dueAlerts(nowMillis: Long): List<ReminderAlert>
|
||||
|
||||
/**
|
||||
* Mark the given alerts handled (`STATE_FIRED`) so a later broadcast does
|
||||
* not surface them again. Best effort: this write needs `WRITE_CALENDAR`,
|
||||
* which the user may have declined — then re-broadcasts silently replace
|
||||
* the already-posted notifications instead (same tag, alert-once).
|
||||
*/
|
||||
fun markFired(alertIds: List<Long>, nowMillis: Long)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class AndroidReminderAlertStore @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ReminderAlertStore {
|
||||
|
||||
override fun dueAlerts(nowMillis: Long): List<ReminderAlert> = context.contentResolver.query(
|
||||
CalendarContract.CalendarAlerts.CONTENT_URI,
|
||||
PROJECTION,
|
||||
CalendarContract.CalendarAlerts.STATE + " = ? AND " +
|
||||
CalendarContract.CalendarAlerts.ALARM_TIME + " <= ?",
|
||||
arrayOf(
|
||||
CalendarContract.CalendarAlerts.STATE_SCHEDULED.toString(),
|
||||
nowMillis.toString(),
|
||||
),
|
||||
CalendarContract.CalendarAlerts.BEGIN + " ASC",
|
||||
)?.use { c ->
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
add(
|
||||
ReminderAlert(
|
||||
alertId = c.getLong(0),
|
||||
eventId = c.getLong(1),
|
||||
calendarId = c.getLong(2),
|
||||
beginMillis = c.getLong(3),
|
||||
endMillis = c.getLong(4),
|
||||
title = c.getString(5).orEmpty(),
|
||||
location = c.getString(6)?.takeIf { it.isNotBlank() },
|
||||
isAllDay = c.getInt(7) == 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
override fun markFired(alertIds: List<Long>, nowMillis: Long) {
|
||||
if (alertIds.isEmpty()) return
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.CalendarAlerts.STATE, CalendarContract.CalendarAlerts.STATE_FIRED)
|
||||
put(CalendarContract.CalendarAlerts.RECEIVED_TIME, nowMillis)
|
||||
put(CalendarContract.CalendarAlerts.NOTIFY_TIME, nowMillis)
|
||||
}
|
||||
try {
|
||||
context.contentResolver.update(
|
||||
CalendarContract.CalendarAlerts.CONTENT_URI,
|
||||
values,
|
||||
CalendarContract.CalendarAlerts._ID +
|
||||
" IN (" + alertIds.joinToString(",") + ")",
|
||||
null,
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Cannot mark alerts fired without WRITE_CALENDAR", e)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderAlertStore"
|
||||
val PROJECTION = arrayOf(
|
||||
CalendarContract.CalendarAlerts._ID,
|
||||
CalendarContract.CalendarAlerts.EVENT_ID,
|
||||
CalendarContract.CalendarAlerts.CALENDAR_ID,
|
||||
CalendarContract.CalendarAlerts.BEGIN,
|
||||
CalendarContract.CalendarAlerts.END,
|
||||
CalendarContract.CalendarAlerts.TITLE,
|
||||
CalendarContract.CalendarAlerts.EVENT_LOCATION,
|
||||
CalendarContract.CalendarAlerts.ALL_DAY,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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.domain.reminders.ReminderEventInstance
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The read side of in-house reminder delivery: occurrences and their reminder
|
||||
* offsets, read from `Instances` and `Reminders` rather than `CalendarAlerts`,
|
||||
* which cannot be assumed to be written (#75).
|
||||
*
|
||||
* An interface so [ReminderScanner] can be exercised on the JVM.
|
||||
*/
|
||||
interface ReminderInstanceSource {
|
||||
|
||||
/** Occurrences overlapping `[fromMillis, toMillis]`, of switched-on calendars. */
|
||||
fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance>
|
||||
|
||||
/** `METHOD_ALERT` reminder offsets per event id, for the given events. */
|
||||
fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>>
|
||||
|
||||
/**
|
||||
* The largest `METHOD_ALERT` offset anywhere in the table, so the query
|
||||
* window can be stretched to cover it and a long-lead reminder is planned
|
||||
* before it comes due rather than firing late.
|
||||
*/
|
||||
fun longestReminderMinutes(): Int
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class ProviderReminderInstanceSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ReminderInstanceSource {
|
||||
|
||||
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
|
||||
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.
|
||||
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
|
||||
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})"
|
||||
return context.contentResolver.query(
|
||||
uri, OCCURRENCE_PROJECTION, selection, null, null,
|
||||
)?.use { c ->
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
add(
|
||||
ReminderEventInstance(
|
||||
eventId = c.getLong(0),
|
||||
calendarId = c.getLong(1),
|
||||
beginMillis = c.getLong(2),
|
||||
endMillis = if (c.isNull(3)) 0L else c.getLong(3),
|
||||
title = c.getString(4).orEmpty(),
|
||||
location = c.getString(5)?.takeIf { it.isNotBlank() },
|
||||
isAllDay = c.getInt(6) == 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
override fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>> {
|
||||
if (eventIds.isEmpty()) return emptyMap()
|
||||
val out = mutableMapOf<Long, MutableList<Int>>()
|
||||
// Batched: the ids go into the selection literally, and an unbounded
|
||||
// `IN (...)` would grow the SQL past what SQLite takes.
|
||||
eventIds.distinct().chunked(EVENT_ID_BATCH).forEach { batch ->
|
||||
context.contentResolver.query(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
REMINDER_PROJECTION,
|
||||
"${CalendarContract.Reminders.METHOD} = " +
|
||||
"${CalendarContract.Reminders.METHOD_ALERT} AND " +
|
||||
"${CalendarContract.Reminders.EVENT_ID} IN (${batch.joinToString(",")})",
|
||||
null,
|
||||
null,
|
||||
)?.use { c ->
|
||||
while (c.moveToNext()) {
|
||||
out.getOrPut(c.getLong(0)) { mutableListOf() } += c.getInt(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
override fun longestReminderMinutes(): Int = context.contentResolver.query(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Reminders.MINUTES),
|
||||
"${CalendarContract.Reminders.METHOD} = ${CalendarContract.Reminders.METHOD_ALERT}",
|
||||
null,
|
||||
// One row is enough: the provider passes the sort order to SQLite.
|
||||
"${CalendarContract.Reminders.MINUTES} DESC",
|
||||
)?.use { c -> if (c.moveToFirst()) c.getInt(0) else 0 } ?: 0
|
||||
|
||||
private companion object {
|
||||
const val EVENT_ID_BATCH = 50
|
||||
|
||||
val OCCURRENCE_PROJECTION = arrayOf(
|
||||
CalendarContract.Instances.EVENT_ID,
|
||||
CalendarContract.Instances.CALENDAR_ID,
|
||||
CalendarContract.Instances.BEGIN,
|
||||
CalendarContract.Instances.END,
|
||||
CalendarContract.Instances.TITLE,
|
||||
CalendarContract.Instances.EVENT_LOCATION,
|
||||
CalendarContract.Instances.ALL_DAY,
|
||||
)
|
||||
|
||||
val REMINDER_PROJECTION = arrayOf(
|
||||
CalendarContract.Reminders.EVENT_ID,
|
||||
CalendarContract.Reminders.MINUTES,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The backstop under the alarm: a daily scan that runs whether or not the alarm
|
||||
* survived. Finds nothing to do in the steady state; it exists for the device
|
||||
* that quietly drops the alarm without a reboot to announce it (#75).
|
||||
*/
|
||||
object ReminderMaintenanceScheduler {
|
||||
|
||||
private const val WORK_NAME = "reminder-scan-maintenance"
|
||||
|
||||
/** Enqueue the daily backstop; idempotent, so every launch may call it. */
|
||||
fun apply(context: Context) {
|
||||
val request = PeriodicWorkRequestBuilder<ReminderMaintenanceWorker>(1, TimeUnit.DAYS)
|
||||
// The launch scan covers now; let the first periodic run wait.
|
||||
.setInitialDelay(1, TimeUnit.DAYS)
|
||||
.build()
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||
}
|
||||
}
|
||||
|
||||
class ReminderMaintenanceWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Deps {
|
||||
fun reminderScanner(): ReminderScanner
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result = try {
|
||||
EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||
.reminderScanner()
|
||||
.scan()
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
// The scan swallows its own failures, so anything reaching here is the
|
||||
// entry point itself — a retry will not mend it.
|
||||
Log.w(TAG, "Reminder maintenance scan failed", e)
|
||||
Result.success()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderMaintenance"
|
||||
}
|
||||
}
|
||||
@@ -14,27 +14,35 @@ import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
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 kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Posts one notification per due reminder alert on a dedicated channel.
|
||||
* Tapping opens the event's detail screen; the tag is the alert id, so a
|
||||
* re-broadcast of an alert we couldn't mark fired replaces its notification
|
||||
* silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of
|
||||
* duplicating it.
|
||||
* Posts one notification per due reminder on a dedicated channel. Tapping opens
|
||||
* the event's detail screen.
|
||||
*
|
||||
* The tag is the reminder's stable key, so a scan that posts the same reminder
|
||||
* again — a catch-up pass overlapping the alarm that already fired — replaces
|
||||
* its notification silently ([NotificationCompat.Builder.setOnlyAlertOnce])
|
||||
* instead of stacking a second one.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderNotifier @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val calendarPrefs: CalendarPrefs,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
) {
|
||||
|
||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||
@@ -45,23 +53,43 @@ class ReminderNotifier @Inject constructor(
|
||||
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
suspend fun post(alert: ReminderAlert) {
|
||||
// The single choke point for the disabled-calendar gate: it covers both
|
||||
// the provider broadcast (EventReminderReceiver) and a snoozed re-show
|
||||
// (ReminderActionReceiver), so a calendar disabled after a snooze no
|
||||
// longer notifies — without either receiver duplicating the check.
|
||||
if (alert.isForDisabledCalendar(calendarPrefs.disabledCalendarIds.first())) return
|
||||
/**
|
||||
* The single choke point for "this calendar is switched off", covering the
|
||||
* two paths that reach [post] around the scan's own filter: a snooze armed
|
||||
* before the switch-off, and a read-only install whose switch lives in
|
||||
* [CalendarPrefs].
|
||||
*/
|
||||
private suspend fun isSilenced(calendarId: Long): Boolean =
|
||||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
||||
calendarDataSource.isCalendarVisible(calendarId) == false
|
||||
|
||||
/**
|
||||
* 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()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
val zone = ZoneId.systemDefault()
|
||||
val locale = Locale.getDefault()
|
||||
// resolveFirstDay yields a kotlinx.datetime day; bridge it to java.time by
|
||||
// its shared ISO number (1..7) for the date math in reminderTimeText.
|
||||
val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber)
|
||||
val time = reminderTimeText(
|
||||
beginMillis = alert.beginMillis,
|
||||
endMillis = alert.endMillis,
|
||||
isAllDay = alert.isAllDay,
|
||||
zone = ZoneId.systemDefault(),
|
||||
locale = Locale.getDefault(),
|
||||
zone = zone,
|
||||
locale = locale,
|
||||
is24Hour = is24Hour,
|
||||
today = Instant.now().atZone(zone).toLocalDate(),
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
||||
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
||||
)
|
||||
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
@@ -88,16 +116,18 @@ class ReminderNotifier @Inject constructor(
|
||||
.build()
|
||||
try {
|
||||
NotificationManagerCompat.from(context)
|
||||
.notify(alert.alertId.toString(), NOTIFICATION_ID, notification)
|
||||
.notify(alert.key.toString(), NOTIFICATION_ID, notification)
|
||||
} catch (e: SecurityException) {
|
||||
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
||||
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
||||
}
|
||||
// Handled either way — a retry hits the same revoked permission.
|
||||
return true
|
||||
}
|
||||
|
||||
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||
fun cancel(alert: ReminderAlert) {
|
||||
NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID)
|
||||
NotificationManagerCompat.from(context).cancel(alert.key.toString(), NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
||||
@@ -110,7 +140,10 @@ class ReminderNotifier @Inject constructor(
|
||||
|
||||
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
/* requestCode = */ alert.alertId.toInt(),
|
||||
// Shares the per-(alert, action) request-code scheme with the buttons.
|
||||
/* requestCode = */ ReminderActionReceiver.requestCode(
|
||||
alert, ReminderActionReceiver.ACTION_OPEN,
|
||||
),
|
||||
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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.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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* One pass of in-house reminder delivery: read what is planned, post what has
|
||||
* come due, and arm the next wake-up. Every trigger runs the same [scan], and
|
||||
* re-running is always safe — the watermark in [ReminderStatePrefs] decides what
|
||||
* is owed, not the trigger.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderScanner @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val source: ReminderInstanceSource,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
private val notifier: ReminderNotifier,
|
||||
private val alarms: ReminderAlarmScheduler,
|
||||
private val state: ReminderStatePrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: kotlinx.coroutines.CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
// Triggers overlap freely (an alarm during a burst of edits); serialize so
|
||||
// two passes can't both read the same watermark and post the same reminder.
|
||||
private val scanLock = Mutex()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + io)
|
||||
private val providerChanges = MutableSharedFlow<Unit>(
|
||||
replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
private var watching = false
|
||||
|
||||
suspend fun scan() = withContext(io) {
|
||||
scanLock.withLock {
|
||||
try {
|
||||
runScan()
|
||||
} catch (e: SecurityException) {
|
||||
// Permission revoked mid-flight; the next grant re-scans.
|
||||
Log.w(TAG, "Reminder scan lacks the calendar permission", e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Reminder scan failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runScan() {
|
||||
val now = System.currentTimeMillis()
|
||||
if (!hasReadCalendar()) return
|
||||
|
||||
if (!settingsPrefs.remindersEnabled.first()) {
|
||||
// Reminders off: drop the wake-up, but keep the watermark moving
|
||||
// so switching them back on doesn't replay the backlog.
|
||||
alarms.cancelScan()
|
||||
state.setLastScanMillis(now)
|
||||
return
|
||||
}
|
||||
|
||||
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
|
||||
// Reach into the past too: an all-day "at time of event" encodes to a
|
||||
// negative offset, and a catch-up pass needs the occurrences it missed.
|
||||
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
|
||||
val planned = planReminders(
|
||||
instances = occurrences,
|
||||
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId }),
|
||||
zone = ZoneId.systemDefault(),
|
||||
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
|
||||
)
|
||||
|
||||
val schedule = scheduleReminders(
|
||||
planned = planned,
|
||||
lastFiredMillis = reminderWatermark(state.lastScanMillis(), now),
|
||||
nowMillis = now,
|
||||
horizonMillis = now + MAX_ALARM_INTERVAL_MILLIS,
|
||||
)
|
||||
|
||||
if (notifier.canPost()) {
|
||||
schedule.due.forEach { notifier.post(it.toAlert()) }
|
||||
}
|
||||
// Advance even when nothing could be posted, so muting notifications
|
||||
// doesn't build a backlog.
|
||||
state.setLastScanMillis(now)
|
||||
alarms.scheduleScan(schedule.nextAlarmMillis)
|
||||
}
|
||||
|
||||
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
|
||||
* alarm at once. Debounced, since a single save lands as several
|
||||
* notifications. Process-lifetime only; other triggers cover the rest.
|
||||
*/
|
||||
fun startWatchingProvider() {
|
||||
if (watching) return
|
||||
watching = true
|
||||
providerChanges
|
||||
.debounce(PROVIDER_CHANGE_DEBOUNCE_MILLIS)
|
||||
.onEach { scan() }
|
||||
.launchIn(scope)
|
||||
calendarDataSource.registerChangeListener { providerChanges.tryEmit(Unit) }
|
||||
}
|
||||
|
||||
/** Fire-and-forget scan for callers that are not in a coroutine already. */
|
||||
fun scanInBackground() {
|
||||
scope.launch(start = CoroutineStart.DEFAULT) { scan() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderScanner"
|
||||
|
||||
/**
|
||||
* How far ahead occurrences are read. Stretched further by the longest
|
||||
* reminder offset in the table, so this is only the floor.
|
||||
*/
|
||||
const val LOOKAHEAD_MILLIS = 7L * 24 * 60 * 60 * 1000
|
||||
|
||||
/** How far back to look for occurrences that may still owe a reminder. */
|
||||
const val PAST_WINDOW_MILLIS = 24L * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Never wait longer than a day for the next pass: it rolls the lookahead
|
||||
* window forward and re-arms an alarm the system may have dropped.
|
||||
*/
|
||||
const val MAX_ALARM_INTERVAL_MILLIS = 24L * 60 * 60 * 1000
|
||||
|
||||
const val PROVIDER_CHANGE_DEBOUNCE_MILLIS = 2_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Every out-of-process reason to re-run a reminder scan: our own [ACTION_SCAN]
|
||||
* alarm, boot and package-replaced (both wipe pending alarms), and time or
|
||||
* timezone changes (both move reminders relative to the armed alarm). All do the
|
||||
* same thing, since [ReminderScanner.scan] is idempotent.
|
||||
*
|
||||
* Exported for the system broadcasts; an early scan triggered by another app is
|
||||
* harmless.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ReminderScheduleReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var scanner: ReminderScanner
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
// Checked despite every action doing the same thing: the receiver is
|
||||
// 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
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
scanner.scan()
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_SCAN = "de.jeanlucmakiola.calendula.reminders.SCAN"
|
||||
|
||||
private val HANDLED_ACTIONS = setOf(
|
||||
ACTION_SCAN,
|
||||
Intent.ACTION_BOOT_COMPLETED,
|
||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||
Intent.ACTION_TIME_CHANGED,
|
||||
Intent.ACTION_TIMEZONE_CHANGED,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,13 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
|
||||
* Schedules a one-off exact alarm that re-shows a snoozed reminder. Separate
|
||||
* from [ReminderAlarmScheduler]'s single moving scan alarm: a snooze is pinned
|
||||
* to one reminder and has to outlive the watermark moving past it, so it carries
|
||||
* the reminder in its own intent.
|
||||
*
|
||||
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
|
||||
* broadcast (the Etar model), but a snoozed reminder has no provider backing —
|
||||
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
|
||||
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
|
||||
* back to an inexact allow-while-idle alarm only if the OS withholds the
|
||||
* exact-alarm capability (API 31–32 where the user revoked it).
|
||||
* Falls back to an inexact allow-while-idle alarm where the OS withholds the
|
||||
* exact-alarm capability (API 31–32 with the permission revoked).
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderSnoozeScheduler @Inject constructor(
|
||||
@@ -37,8 +36,8 @@ class ReminderSnoozeScheduler @Inject constructor(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
} else {
|
||||
// Exact alarms revoked (API 31–32): an inexact wake is the honest
|
||||
// best we can do without nagging for SCHEDULE_EXACT_ALARM.
|
||||
// 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,
|
||||
)
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The one line of time context in a reminder notification. Pure so it can be
|
||||
* JVM-tested:
|
||||
* JVM-tested.
|
||||
*
|
||||
* - timed, same day: "09:30 – 10:00"
|
||||
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time)
|
||||
* Timed events that fall on a day other than [today] are prefixed with that
|
||||
* day, so a reminder fired ahead of time no longer reads as if the event were
|
||||
* today (issue #46). The prefix prefers natural language and stays short:
|
||||
*
|
||||
* - today: "09:30 – 10:00" (no prefix)
|
||||
* - tomorrow / yesterday: "Tomorrow, 09:30 – 10:00" ([tomorrowLabel] / [yesterdayLabel])
|
||||
* - elsewhere this week: "Thu, 09:30 – 10:00" (localized short weekday)
|
||||
* - further out: "16 Jul, 09:30 – 10:00" (medium date — a weekday
|
||||
* 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: "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
|
||||
* 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(
|
||||
@@ -27,6 +41,10 @@ fun reminderTimeText(
|
||||
zone: ZoneId,
|
||||
locale: Locale,
|
||||
is24Hour: Boolean,
|
||||
today: LocalDate,
|
||||
firstDayOfWeek: DayOfWeek,
|
||||
tomorrowLabel: String,
|
||||
yesterdayLabel: String,
|
||||
): String {
|
||||
if (isAllDay) {
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
@@ -43,18 +61,70 @@ fun reminderTimeText(
|
||||
}
|
||||
|
||||
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
||||
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
||||
return if (begin.toLocalDate() == end.toLocalDate()) {
|
||||
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||
val range = timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||
val prefix = relativeDayPrefix(
|
||||
day = begin.toLocalDate(),
|
||||
today = today,
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
locale = locale,
|
||||
dateFormat = dateFormat,
|
||||
tomorrowLabel = tomorrowLabel,
|
||||
yesterdayLabel = yesterdayLabel,
|
||||
)
|
||||
if (prefix == null) range else "$prefix, $range"
|
||||
} else {
|
||||
// Cross-day: medium date + the chosen short time, joined per side. Built
|
||||
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
|
||||
// applies to the time portion too.
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
// applies to the time portion too. The explicit dates already say which
|
||||
// day, so no relative prefix is layered on top.
|
||||
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
|
||||
dateTime(begin) + RANGE + dateTime(end)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A short label for [day] relative to [today], or `null` when it *is* today (the
|
||||
* common case, which needs no prefix). Weekday names are used only within the
|
||||
* current week — a "next Wednesday" would be indistinguishable from this one, so
|
||||
* anything past this week falls back to the exact date.
|
||||
*/
|
||||
private fun relativeDayPrefix(
|
||||
day: LocalDate,
|
||||
today: LocalDate,
|
||||
firstDayOfWeek: DayOfWeek,
|
||||
locale: Locale,
|
||||
dateFormat: DateTimeFormatter,
|
||||
tomorrowLabel: String,
|
||||
yesterdayLabel: String,
|
||||
): String? = when (ChronoUnit.DAYS.between(today, day)) {
|
||||
0L -> null
|
||||
1L -> tomorrowLabel
|
||||
-1L -> yesterdayLabel
|
||||
else -> if (isSameWeek(day, today, firstDayOfWeek)) {
|
||||
day.dayOfWeek.getDisplayName(TextStyle.SHORT, locale)
|
||||
} else {
|
||||
dateFormat.format(day)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [day] and [today] share the same week. The week boundary honours the
|
||||
* user's *week starts on* setting (already resolved to a concrete [firstDayOfWeek],
|
||||
* with [firstDayOfWeek] falling back to the locale default upstream).
|
||||
*/
|
||||
private fun isSameWeek(day: LocalDate, today: LocalDate, firstDayOfWeek: DayOfWeek): Boolean {
|
||||
val startOfWeek = today.previousOrSame(firstDayOfWeek)
|
||||
return !day.isBefore(startOfWeek) && day.isBefore(startOfWeek.plusWeeks(1))
|
||||
}
|
||||
|
||||
/** The most recent [target] on or before this date (this date itself when it matches). */
|
||||
private fun LocalDate.previousOrSame(target: DayOfWeek): LocalDate {
|
||||
val backtrack = (dayOfWeek.value - target.value + 7) % 7
|
||||
return minusDays(backtrack.toLong())
|
||||
}
|
||||
|
||||
private const val RANGE = " – "
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import java.util.Base64
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Still relevant while the event has not ended: a reminder for an event that is
|
||||
* already over is pointless to re-surface. Falls back to the begin time when the
|
||||
* end is unknown (0L). Used both to decide what to re-post and to purge the stash.
|
||||
*/
|
||||
internal fun ReminderAlert.isRelevantAt(nowMillis: Long): Boolean =
|
||||
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
|
||||
|
||||
/**
|
||||
* Local stash of reminder alerts that fired while their calendar was disabled
|
||||
* in-app. [EventReminderReceiver] marks every due alert `STATE_FIRED` regardless
|
||||
* (so the provider stops re-broadcasting the suppressed ones), which would
|
||||
* otherwise lose those reminders forever — there is no re-scan. Stashing lets
|
||||
* [de.jeanlucmakiola.calendula.ui.calendars.CalendarsViewModel] re-post them if
|
||||
* the user re-enables the calendar before the event is over.
|
||||
*
|
||||
* Persisted in the shared preferences DataStore as a set of self-describing
|
||||
* strings (one per alert); the stash never reaches a screen, so there is no
|
||||
* domain model. Entries whose event has already ended are dropped on the next
|
||||
* stash/recover/purge, so the stash only ever holds a handful of pending alerts.
|
||||
*/
|
||||
@Singleton
|
||||
class SuppressedReminderStore @Inject constructor(
|
||||
private val store: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
/** Add [alerts] to the stash, replacing any existing entry with the same id. */
|
||||
suspend fun stash(alerts: List<ReminderAlert>, nowMillis: Long) {
|
||||
if (alerts.isEmpty()) return
|
||||
store.edit { prefs ->
|
||||
val byId = decodeAll(prefs).associateByTo(mutableMapOf()) { it.alertId }
|
||||
alerts.forEach { byId[it.alertId] = it }
|
||||
prefs.putStash(byId.values.filter { it.isRelevantAt(nowMillis) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove and return the still-relevant stashed alerts belonging to any of
|
||||
* [calendarIds]; drops expired entries for every calendar in passing.
|
||||
*/
|
||||
suspend fun recoverFor(calendarIds: Set<Long>, nowMillis: Long): List<ReminderAlert> {
|
||||
val recovered = mutableListOf<ReminderAlert>()
|
||||
store.edit { prefs ->
|
||||
val kept = decodeAll(prefs).filter { alert ->
|
||||
when {
|
||||
!alert.isRelevantAt(nowMillis) -> false // expired: drop
|
||||
alert.calendarId in calendarIds -> { recovered += alert; false }
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
prefs.putStash(kept)
|
||||
}
|
||||
return recovered
|
||||
}
|
||||
|
||||
/** Drop entries whose event has already ended — cheap opportunistic cleanup. */
|
||||
suspend fun purgeExpired(nowMillis: Long) {
|
||||
store.edit { prefs ->
|
||||
prefs.putStash(decodeAll(prefs).filter { it.isRelevantAt(nowMillis) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeAll(prefs: Preferences): List<ReminderAlert> =
|
||||
prefs[KEY].orEmpty().mapNotNull { decodeStashEntry(it) }
|
||||
|
||||
private fun MutablePreferences.putStash(alerts: List<ReminderAlert>) {
|
||||
val encoded = alerts.map { encodeStashEntry(it) }.toSet()
|
||||
if (encoded.isEmpty()) remove(KEY) else set(KEY, encoded)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val KEY = stringSetPreferencesKey("suppressed_reminders")
|
||||
}
|
||||
}
|
||||
|
||||
// One stash entry as a delimited string. The '|' separator is safe because every
|
||||
// free-text field is Base64-encoded first (that alphabet never contains '|'), and
|
||||
// a null location is stored as a distinct sentinel that Base64 also never yields.
|
||||
private const val FIELD_SEP = "|"
|
||||
private const val NULL_LOCATION = "-"
|
||||
|
||||
internal fun encodeStashEntry(alert: ReminderAlert): String = listOf(
|
||||
alert.alertId.toString(),
|
||||
alert.eventId.toString(),
|
||||
alert.calendarId.toString(),
|
||||
alert.beginMillis.toString(),
|
||||
alert.endMillis.toString(),
|
||||
if (alert.isAllDay) "1" else "0",
|
||||
alert.title.toBase64(),
|
||||
alert.location?.toBase64() ?: NULL_LOCATION,
|
||||
).joinToString(FIELD_SEP)
|
||||
|
||||
/** Reverse of [encodeStashEntry]; returns null for a malformed entry (dropped). */
|
||||
internal fun decodeStashEntry(raw: String): ReminderAlert? {
|
||||
val parts = raw.split(FIELD_SEP)
|
||||
if (parts.size != 8) return null
|
||||
return try {
|
||||
ReminderAlert(
|
||||
alertId = parts[0].toLong(),
|
||||
eventId = parts[1].toLong(),
|
||||
calendarId = parts[2].toLong(),
|
||||
beginMillis = parts[3].toLong(),
|
||||
endMillis = parts[4].toLong(),
|
||||
title = parts[6].fromBase64(),
|
||||
location = parts[7].takeIf { it != NULL_LOCATION }?.fromBase64(),
|
||||
isAllDay = parts[5] == "1",
|
||||
)
|
||||
} catch (e: NumberFormatException) {
|
||||
null
|
||||
} catch (e: IllegalArgumentException) { // bad Base64
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toBase64(): String =
|
||||
Base64.getEncoder().encodeToString(toByteArray(Charsets.UTF_8))
|
||||
|
||||
private fun String.fromBase64(): String =
|
||||
String(Base64.getDecoder().decode(this), Charsets.UTF_8)
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
/**
|
||||
* The ways a calendar can behave unlike a plain, writable one — each a reason it
|
||||
* is missing from the event and import pickers (#76).
|
||||
*/
|
||||
enum class CalendarStateLabel {
|
||||
/**
|
||||
* A special-dates mirror the app fills from contacts. Writable and visible,
|
||||
* yet no event target: anything authored here is deleted by the next sync.
|
||||
*/
|
||||
MANAGED,
|
||||
|
||||
/** Contents can't be modified: a WebCal subscription, a read-only share. */
|
||||
READ_ONLY,
|
||||
|
||||
/** The account holds the events, but this device isn't syncing them down. */
|
||||
NOT_SYNCED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account keeps this calendar's events off the device
|
||||
* (`Calendars.SYNC_EVENTS = 0`) — empty by construction. Device-local calendars
|
||||
* are excluded: nothing syncs them by definition, and one from another app can
|
||||
* hold real events at `sync_events = 0`.
|
||||
*/
|
||||
val CalendarSource.isNotSynced: Boolean
|
||||
get() = !syncsEvents && !isLocal
|
||||
|
||||
/**
|
||||
* Whether a visibility switch on this calendar can change anything the user
|
||||
* would see — it can't for a non-syncing one, with no events on the device.
|
||||
*/
|
||||
val CalendarSource.hasVisibilitySwitch: Boolean
|
||||
get() = !isNotSynced
|
||||
|
||||
/**
|
||||
* Whether this calendar can be offered as a target for a new or imported event.
|
||||
* The one predicate behind both pickers, so the states [CalendarStateLabel]
|
||||
* names on a manager row are exactly the states that keep a calendar out of them
|
||||
* (#76). An event already living in an excluded calendar keeps it; the editor
|
||||
* adds that calendar back to its picker.
|
||||
*/
|
||||
val CalendarSource.isEventTarget: Boolean
|
||||
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
|
||||
|
||||
/** Every state worth naming on this calendar's row, in reading order. */
|
||||
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
||||
if (isManaged) add(CalendarStateLabel.MANAGED)
|
||||
if (!canModifyContents) add(CalendarStateLabel.READ_ONLY)
|
||||
if (isNotSynced) add(CalendarStateLabel.NOT_SYNCED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar-manager order within one group: the ones you can actually act on
|
||||
* first, the non-syncing ones after them. Stable otherwise, so the provider's
|
||||
* display-name ordering survives.
|
||||
*/
|
||||
fun List<CalendarSource>.orderedForManager(): List<CalendarSource> =
|
||||
sortedBy { it.isNotSynced }
|
||||
@@ -0,0 +1,48 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
/**
|
||||
* The `Calendars.VISIBLE` writes that flush the app's pending "switched off"
|
||||
* set into the provider, plus the ids that need no write at all.
|
||||
*/
|
||||
data class CalendarVisibilityPlan(
|
||||
val hide: Set<Long> = emptySet(),
|
||||
val settled: Set<Long> = emptySet(),
|
||||
) {
|
||||
val isEmpty: Boolean get() = hide.isEmpty() && settled.isEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile [pendingDisabledIds] — switch-offs the app could not write, plus
|
||||
* what the retired app-local visibility model left behind (#75) — against the
|
||||
* calendars actually on the device.
|
||||
*
|
||||
* Only ever *hides*: switching a system-hidden calendar back on would un-hide it
|
||||
* in every other calendar app too. Calendula follows the flag and explains
|
||||
* itself once (see [hasSystemHiddenCalendars]).
|
||||
*
|
||||
* [CalendarVisibilityPlan.settled] carries the ids needing no write — already
|
||||
* hidden, or gone from the device.
|
||||
*/
|
||||
fun calendarVisibilityPlan(
|
||||
calendars: List<CalendarSource>,
|
||||
pendingDisabledIds: Set<Long>,
|
||||
): CalendarVisibilityPlan {
|
||||
val byId = calendars.associateBy { it.id }
|
||||
val hide = mutableSetOf<Long>()
|
||||
val settled = mutableSetOf<Long>()
|
||||
for (id in pendingDisabledIds) {
|
||||
val calendar = byId[id]
|
||||
// Gone from the device, or already invisible — nothing to write.
|
||||
if (calendar != null && calendar.isVisibleInSystem) hide += id else settled += id
|
||||
}
|
||||
return CalendarVisibilityPlan(hide = hide, settled = settled)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any calendar is switched off at system level without Calendula having
|
||||
* asked for it — the condition the one-time notice explains.
|
||||
*/
|
||||
fun hasSystemHiddenCalendars(
|
||||
calendars: List<CalendarSource>,
|
||||
pendingDisabledIds: Set<Long>,
|
||||
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
|
||||
@@ -0,0 +1,185 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cbrt
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Curates an account's published event palette for the colour picker.
|
||||
*
|
||||
* Sync adapters differ wildly in what they publish: Google exposes a
|
||||
* hand-picked two-dozen set, while CalDAV adapters (DAVx5) dump all ~147 CSS3
|
||||
* named colours — including exact-value aliases (aqua/cyan, the gray/grey
|
||||
* spelling pairs) and dozens of visually indistinguishable whites and grays
|
||||
* (#22).
|
||||
*
|
||||
* Crucially, curation runs against the colour the picker actually *paints*, not
|
||||
* the raw provider value. The picker softens every swatch through [pastelArgb]:
|
||||
* it pins lightness to a constant and caps saturation, so the raw palette's
|
||||
* lightness axis is invisible on screen. Two raw colours that look different —
|
||||
* a navy and a mid blue — paint as one swatch, and every neutral (black, the
|
||||
* grays, white) paints as the same pale tint. Judging distinctness in raw
|
||||
* space, as before, left near-identical painted swatches and stranded the
|
||||
* neutrals as a run of look-alike "pinks" at the end of the grid.
|
||||
*
|
||||
* Three steps, all in painted space:
|
||||
* 1. Collapse swatches that paint identically to one (alphabetically-first key
|
||||
* wins, deterministically) — this folds aliases, dark/light shades of a
|
||||
* hue, and all the neutrals together.
|
||||
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out
|
||||
* neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are
|
||||
* then thinned to visually distinct colours: most vivid first, a colour is
|
||||
* kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every
|
||||
* colour already kept. Small palettes are already curated by their adapter
|
||||
* and pass through whole.
|
||||
* 3. The survivors are ordered like a rainbow — continuously by painted hue —
|
||||
* with the wheel cut at its single widest empty gap so the one unavoidable
|
||||
* seam lands in dead space and no hue family is torn across both ends.
|
||||
*
|
||||
* Every surviving option keeps its provider [EventColorOption.key], so a pick
|
||||
* still round-trips through sync.
|
||||
*/
|
||||
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||
val painted = sortedBy { it.key }
|
||||
.distinctBy { pastelArgb(it.argb) }
|
||||
.map { it to Lab.of(pastelArgb(it.argb)) }
|
||||
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
|
||||
painted
|
||||
} else {
|
||||
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
|
||||
}
|
||||
return orderAroundWheel(kept).map { (option, _) -> option }
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders swatches continuously around the (painted) hue wheel, then cuts the
|
||||
* circle at its widest angular gap so the single seam lands in empty space
|
||||
* instead of mid-family. Saturation breaks ties, vivid first.
|
||||
*/
|
||||
private fun orderAroundWheel(
|
||||
swatches: List<Pair<EventColorOption, Lab>>,
|
||||
): List<Pair<EventColorOption, Lab>> {
|
||||
if (swatches.size < 2) return swatches
|
||||
val byHue = swatches.sortedWith(
|
||||
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }),
|
||||
)
|
||||
// Split the wheel after the largest empty arc between neighbouring hues;
|
||||
// the default is the wrap gap (last hue back round to the first), i.e. the
|
||||
// familiar 0→360 order, and we only rotate away from it for a wider void.
|
||||
var cutAfter = byHue.lastIndex
|
||||
var widestGap = 360.0 - byHue.last().second.hue + byHue.first().second.hue
|
||||
for (i in 0 until byHue.lastIndex) {
|
||||
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
|
||||
if (gap > widestGap) {
|
||||
widestGap = gap
|
||||
cutAfter = i
|
||||
}
|
||||
}
|
||||
return byHue.subList(cutAfter + 1, byHue.size) + byHue.subList(0, cutAfter + 1)
|
||||
}
|
||||
|
||||
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
||||
private fun thin(
|
||||
swatches: List<Pair<EventColorOption, Lab>>,
|
||||
): List<Pair<EventColorOption, Lab>> {
|
||||
val byVividness = swatches
|
||||
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
|
||||
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
|
||||
for (candidate in byVividness) {
|
||||
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
/**
|
||||
* The softening the colour picker paints over every swatch: keep the hue, scale
|
||||
* and clamp saturation into a gentle band, and pin value to a constant so
|
||||
* nothing screams and everything reads on the surface. Value is fixed here so
|
||||
* curation is theme-independent — only hue and saturation distinguish painted
|
||||
* swatches.
|
||||
*
|
||||
* This is a self-contained mirror of floret-kit's `pastelize` hue/saturation
|
||||
* shaping (`de.jeanlucmakiola.floret.components.pastelize`), with value pinned
|
||||
* rather than theme-picked. Curation must reason about the colour the picker
|
||||
* paints, so the two shapings have to agree: if floret's saturation band or
|
||||
* curve changes, update this in step.
|
||||
*/
|
||||
fun pastelArgb(rawArgb: Int): Int {
|
||||
val r = ((rawArgb shr 16) and 0xFF) / 255f
|
||||
val g = ((rawArgb shr 8) and 0xFF) / 255f
|
||||
val b = (rawArgb and 0xFF) / 255f
|
||||
val max = maxOf(r, g, b)
|
||||
val min = minOf(r, g, b)
|
||||
val delta = max - min
|
||||
val hue = when {
|
||||
delta == 0f -> 0f
|
||||
max == r -> 60f * (((g - b) / delta) % 6f)
|
||||
max == g -> 60f * (((b - r) / delta) + 2f)
|
||||
else -> 60f * (((r - g) / delta) + 4f)
|
||||
}.let { if (it < 0f) it + 360f else it }
|
||||
val sat = (if (max == 0f) 0f else delta / max) * 0.6f
|
||||
val s = sat.coerceIn(0.25f, 0.65f)
|
||||
val v = PASTEL_VALUE
|
||||
val c = v * s
|
||||
val x = c * (1f - abs((hue / 60f) % 2f - 1f))
|
||||
val m = v - c
|
||||
val (rr, gg, bb) = when {
|
||||
hue < 60f -> Triple(c, x, 0f)
|
||||
hue < 120f -> Triple(x, c, 0f)
|
||||
hue < 180f -> Triple(0f, c, x)
|
||||
hue < 240f -> Triple(0f, x, c)
|
||||
hue < 300f -> Triple(x, 0f, c)
|
||||
else -> Triple(c, 0f, x)
|
||||
}
|
||||
fun channel(value: Float) = ((value + m) * 255f).roundToInt().coerceIn(0, 255)
|
||||
return (0xFF shl 24) or (channel(rr) shl 16) or (channel(gg) shl 8) or channel(bb)
|
||||
}
|
||||
|
||||
/** Reference lightness for curation; the picker paints at this on dark surfaces. */
|
||||
private const val PASTEL_VALUE = 0.82f
|
||||
|
||||
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
|
||||
private const val CURATION_TRIGGER_SIZE = 36
|
||||
|
||||
/** Minimum CIE76 ΔE between surviving painted swatches. */
|
||||
private const val MIN_DELTA_E = 13.0
|
||||
|
||||
/**
|
||||
* Painted-chroma floor for oversized palettes: below this a swatch is a washed-
|
||||
* out tint — the neutrals and near-whites the saturation clamp muddies — so it
|
||||
* is dropped rather than shown as pale filler.
|
||||
*/
|
||||
private const val PASTEL_CHROMA_FLOOR = 22.0
|
||||
|
||||
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
|
||||
private class Lab(val l: Double, val a: Double, val b: Double) {
|
||||
val chroma: Double get() = hypot(a, b)
|
||||
|
||||
/** Hue angle in degrees, 0–360, around the Lab a-b plane. */
|
||||
val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
|
||||
|
||||
fun deltaE(other: Lab): Double =
|
||||
sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
|
||||
|
||||
companion object {
|
||||
fun of(argb: Int): Lab {
|
||||
fun linear(shift: Int): Double {
|
||||
val c = ((argb shr shift) and 0xFF) / 255.0
|
||||
return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
|
||||
}
|
||||
val r = linear(16)
|
||||
val g = linear(8)
|
||||
val b = linear(0)
|
||||
val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
|
||||
val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
|
||||
fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0
|
||||
val fy = f(y)
|
||||
return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,14 @@ 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.Instant
|
||||
|
||||
/**
|
||||
* User input for creating an event (and, from v1.3, editing one). Times are
|
||||
* wall-clock values in the device zone; the data layer translates them to
|
||||
* provider millis (all-day events normalise to UTC midnights there).
|
||||
* wall-clock values in [timezone]; the data layer translates them to provider
|
||||
* millis (all-day events normalise to UTC midnights there).
|
||||
*/
|
||||
data class EventForm(
|
||||
val calendarId: Long?,
|
||||
@@ -18,6 +19,23 @@ data class EventForm(
|
||||
val isAllDay: Boolean = false,
|
||||
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
|
||||
* when this is saved", which is what an event authored and lived in one
|
||||
* place wants. The data layer resolves it at write time and always stamps a
|
||||
* concrete `EVENT_TIMEZONE`.
|
||||
*
|
||||
* A non-null value pins the event to a zone regardless of where the device
|
||||
* is, so it keeps tracking that zone's offset across DST. [toEditForm] only
|
||||
* sets it when the stored zone differs from the device's, so merely opening
|
||||
* a local event never reveals the field — and re-opening a pinned one in
|
||||
* another zone round-trips it rather than silently re-anchoring it.
|
||||
*
|
||||
* Always null for all-day events: those are date-anchored, not zone-anchored
|
||||
* (see [EventFormField.Timezone] and the data layer's UTC-midnight rule).
|
||||
*/
|
||||
val timezone: String? = null,
|
||||
val location: String = "",
|
||||
val description: String = "",
|
||||
/** Reminder lead times in minutes before the start, deduplicated. */
|
||||
@@ -66,11 +84,19 @@ data class EventAttendee(
|
||||
|
||||
/**
|
||||
* The form's optional sections. Which ones show by default is a user setting;
|
||||
* the rest unfold behind a "more fields" button.
|
||||
* the rest unfold behind a "more fields" button. Declaration order is the order
|
||||
* they're offered in, so a new constant goes where it belongs on the form, not
|
||||
* at the end.
|
||||
*/
|
||||
enum class EventFormField {
|
||||
Location,
|
||||
Description,
|
||||
/**
|
||||
* Pins the event's wall-clock times to a zone. Offered right after the time
|
||||
* fields it qualifies, and suppressed entirely for all-day events, whose
|
||||
* dates are deliberately zone-free.
|
||||
*/
|
||||
Timezone,
|
||||
Reminders,
|
||||
Recurrence,
|
||||
Availability,
|
||||
@@ -100,8 +126,25 @@ enum class EventFormProblem {
|
||||
* All-day provider times are UTC midnights with an exclusive end; the form
|
||||
* shows the last covered day and keeps placeholder wall-clock times in case
|
||||
* the user switches the event to timed.
|
||||
*
|
||||
* A timed event stored in a zone other than [zone] is prefilled *in its own
|
||||
* zone* and keeps it pinned, so the wall-clock the form shows is the one the
|
||||
* event means ("the New York 09:00 call") and a later save re-anchors it to the
|
||||
* same zone rather than the device's.
|
||||
*/
|
||||
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 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.
|
||||
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
|
||||
}
|
||||
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
|
||||
val (start, end) = if (instance.isAllDay) {
|
||||
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
||||
.toLocalDateTime(TimeZone.UTC).date
|
||||
@@ -110,8 +153,8 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||
} else {
|
||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to
|
||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone)
|
||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
|
||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = instance.calendarId,
|
||||
@@ -119,6 +162,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
isAllDay = instance.isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
timezone = pinnedZone,
|
||||
location = instance.location.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
||||
@@ -176,6 +220,23 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
||||
rowEnd = instance.end,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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 lets the UI
|
||||
* show the other side of the pair rather than making the user do the arithmetic.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* The optional sections that hold a value in [form] — when editing, these
|
||||
* must be visible regardless of the user's default-fields setting, or the
|
||||
@@ -184,6 +245,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
||||
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||
if (location.isNotBlank()) add(EventFormField.Location)
|
||||
if (description.isNotBlank()) add(EventFormField.Description)
|
||||
if (timezone != null) add(EventFormField.Timezone)
|
||||
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
||||
if (rrule != null) add(EventFormField.Recurrence)
|
||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
data class CalendarSource(
|
||||
@@ -8,6 +12,11 @@ data class CalendarSource(
|
||||
val accountName: String,
|
||||
val accountType: String,
|
||||
val color: Int,
|
||||
/**
|
||||
* The system's `Calendars.VISIBLE` flag — the single visibility model,
|
||||
* deciding both what Calendula shows and whether this calendar plans
|
||||
* reminders (#75). The drawer's filter sheet is a separate in-app declutter.
|
||||
*/
|
||||
val isVisibleInSystem: Boolean,
|
||||
/**
|
||||
* Whether events in this calendar can be created/edited/deleted
|
||||
@@ -34,6 +43,13 @@ data class CalendarSource(
|
||||
* even after a backup restore clears the app's stored ids.
|
||||
*/
|
||||
val isManaged: Boolean = false,
|
||||
/**
|
||||
* Whether the provider keeps this calendar's events on the device
|
||||
* (`Calendars.SYNC_EVENTS`), independent of [isVisibleInSystem]. Says
|
||||
* nothing about device-local calendars, which can hold events with it off.
|
||||
* Read for the "not synced" row label (#76).
|
||||
*/
|
||||
val syncsEvents: Boolean = true,
|
||||
)
|
||||
|
||||
data class EventInstance(
|
||||
@@ -56,6 +72,31 @@ data class EventInstance(
|
||||
*/
|
||||
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||
|
||||
/**
|
||||
* The zone this event's calendar dates live in: the device [zone] for timed
|
||||
* events, UTC for all-day ones, whose midnights would otherwise shift day
|
||||
* boundaries (#65, #82). Every surface naming an all-day date goes through here.
|
||||
*/
|
||||
fun EventInstance.dateZone(zone: TimeZone): TimeZone =
|
||||
if (isAllDay) TimeZone.UTC else zone
|
||||
|
||||
/** The first calendar day this event occupies. */
|
||||
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
|
||||
start.toLocalDateTime(dateZone(zone)).date
|
||||
|
||||
/**
|
||||
* The last calendar day this event occupies. An event ending exactly at midnight
|
||||
* does not reach into that day, so resolve just before [EventInstance.end].
|
||||
*/
|
||||
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
||||
val lastInstant = if (end > start) end - 1.milliseconds else start
|
||||
return lastInstant.toLocalDateTime(dateZone(zone)).date
|
||||
}
|
||||
|
||||
/** Whether this event occupies more than one calendar day in [zone]. */
|
||||
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
||||
spanFirstDay(zone) != spanLastDay(zone)
|
||||
|
||||
data class EventDetail(
|
||||
val instance: EventInstance,
|
||||
val description: String?,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.plus
|
||||
|
||||
/**
|
||||
* The first [limit] dates a [SimpleRecurrence] fires on, starting at [start]
|
||||
* (DTSTART), for previewing a rule as dates instead of as words. A preview only,
|
||||
* kept to the shapes the picker can build; the provider stays the authority.
|
||||
*
|
||||
* Mirrors RFC 5545: [start] is always the first occurrence (§3.8.5.3), even when
|
||||
* the rule's own picks miss it; a monthly or yearly rule *skips* a period the
|
||||
* start day doesn't exist in rather than clamping; a weekly rule repeats in
|
||||
* blocks of `interval` weeks beginning on Monday (the default WKST, since
|
||||
* [toRRule] never writes one); [RecurrenceEnd.Count] counts real occurrences and
|
||||
* [RecurrenceEnd.Until] is inclusive.
|
||||
*
|
||||
* Returns fewer than [limit] dates when the series ends first, and an empty list
|
||||
* only when the rule yields nothing at all (an UNTIL before [start]).
|
||||
*/
|
||||
fun SimpleRecurrence.upcomingOccurrences(start: LocalDate, limit: Int): List<LocalDate> {
|
||||
if (limit <= 0) return emptyList()
|
||||
val until = (end as? RecurrenceEnd.Until)?.date
|
||||
val maxCount = (end as? RecurrenceEnd.Count)?.times ?: Int.MAX_VALUE
|
||||
val wanted = minOf(limit, maxCount)
|
||||
if (wanted <= 0) return emptyList()
|
||||
if (until != null && start > until) return emptyList()
|
||||
|
||||
// DTSTART is in the recurrence set whatever the rule picks, so seed with it
|
||||
// and let the walk skip anything landing on or before it.
|
||||
val result = mutableListOf(start)
|
||||
var period = 0
|
||||
// Periods can yield nothing (a skipped 31st), so the cap counts periods
|
||||
// examined rather than dates found.
|
||||
while (result.size < wanted && period < MAX_PERIODS) {
|
||||
for (date in occurrencesInPeriod(period, start)) {
|
||||
if (date <= start) continue
|
||||
if (until != null && date > until) return result
|
||||
result += date
|
||||
if (result.size == wanted) return result
|
||||
}
|
||||
period++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** The dates this rule's [period]-th repetition yields (empty when skipped). */
|
||||
private fun SimpleRecurrence.occurrencesInPeriod(period: Int, start: LocalDate): List<LocalDate> =
|
||||
when (freq) {
|
||||
RecurrenceFreq.Daily -> listOf(start.plus(period * interval, DateTimeUnit.DAY))
|
||||
RecurrenceFreq.Weekly -> weeklyOccurrences(period, start)
|
||||
RecurrenceFreq.Monthly -> {
|
||||
val month = start.plus(period * interval, DateTimeUnit.MONTH)
|
||||
// plus() clamps into the shorter month, but the rule skips such a
|
||||
// period — so a clamped date means "not this month".
|
||||
listOfNotNull(dateOrNull(month.year, month.month.number, start.day))
|
||||
}
|
||||
RecurrenceFreq.Yearly ->
|
||||
listOfNotNull(dateOrNull(start.year + period * interval, start.month.number, start.day))
|
||||
}
|
||||
|
||||
/**
|
||||
* One weekly block: every picked weekday inside the week that begins
|
||||
* `period * interval` weeks after the start's own week, in weekday order. With
|
||||
* no picks the rule simply repeats the start's weekday.
|
||||
*/
|
||||
private fun SimpleRecurrence.weeklyOccurrences(period: Int, start: LocalDate): List<LocalDate> {
|
||||
if (byDays.isEmpty()) return listOf(start.plus(period * interval, DateTimeUnit.WEEK))
|
||||
val daysIntoWeek = (start.dayOfWeek.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) %
|
||||
DAYS_PER_WEEK
|
||||
val weekStart = start
|
||||
.minus(daysIntoWeek, DateTimeUnit.DAY)
|
||||
.plus(period * interval, DateTimeUnit.WEEK)
|
||||
return byDays.sortedBy { it.isoDayNumber }.map { day ->
|
||||
weekStart.plus(
|
||||
(day.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) % DAYS_PER_WEEK,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a run of [occurrences] starting at [start] leaves its starting year,
|
||||
* i.e. whether showing them without a year would be ambiguous — a yearly rule
|
||||
* would otherwise read as the same date repeated.
|
||||
*/
|
||||
fun occurrencesSpanYears(occurrences: List<LocalDate>, start: LocalDate): Boolean =
|
||||
occurrences.any { it.year != start.year } || occurrences.map { it.year }.distinct().size > 1
|
||||
|
||||
/** [LocalDate] for a day-of-month that may not exist in that month; null if it doesn't. */
|
||||
private fun dateOrNull(year: Int, month: Int, day: Int): LocalDate? =
|
||||
runCatching { LocalDate(year, month, day) }.getOrNull()
|
||||
|
||||
private const val DAYS_PER_WEEK = 7
|
||||
|
||||
/**
|
||||
* How many repetitions to examine before giving up: generous enough for the
|
||||
* sparsest rule the picker can build, bounded so a rule whose occurrences all
|
||||
* fall outside its own UNTIL can't spin.
|
||||
*/
|
||||
private const val MAX_PERIODS = 2_000
|
||||
@@ -0,0 +1,225 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import java.text.Normalizer
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* One selectable zone, resolved for display at a given instant. [id] is the IANA
|
||||
* id we store in `EVENT_TIMEZONE` and show as the primary label (via [label]);
|
||||
* [shortName] its abbreviation ("CET", "WET", "UTC", or a "GMT+05:30" fallback);
|
||||
* [offsetMinutes] its offset. Both the abbreviation and the offset shift with
|
||||
* DST, so they're only meaningful next to the moment they were resolved for.
|
||||
*
|
||||
* [displayName] is the long localized name ("Central European Time"). It's kept
|
||||
* for search only — matching on it lets someone type "pacific" — and is *not*
|
||||
* displayed: spelled out next to the id it made the field too wide to fit.
|
||||
*/
|
||||
data class TimeZoneOption(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val shortName: String,
|
||||
val offsetMinutes: Int,
|
||||
) {
|
||||
/** The id as the primary label, underscores undone ("America/New York"). */
|
||||
val label: String get() = id.replace('_', ' ')
|
||||
|
||||
/** The trailing segment of the id ("Europe/Berlin" -> "Berlin"), underscores undone. */
|
||||
val city: String get() = id.substringAfterLast('/').replace('_', ' ')
|
||||
|
||||
/** The leading segment ("Europe/Berlin" -> "Europe"); empty for bare ids like "UTC". */
|
||||
val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
|
||||
/**
|
||||
* The four fields [filterTimeZones] matches on, normalized once here rather
|
||||
* than per query. Normalizing is not cheap — NFD decomposition plus a combining-
|
||||
* mark strip — and a search re-examines every option on every keystroke, so
|
||||
* doing it at construction turns ~2400 normalizations per character into a few
|
||||
* hundred plain `startsWith`/`contains` calls.
|
||||
*
|
||||
* Declared in the class body, so it stays out of `equals`/`hashCode`/`copy`:
|
||||
* it is derived state, and two options with the same id are the same option.
|
||||
*/
|
||||
internal val searchKeys: TimeZoneSearchKeys = TimeZoneSearchKeys(
|
||||
city = city.normalizeForSearch(),
|
||||
id = id.normalizeForSearch(),
|
||||
displayName = displayName.normalizeForSearch(),
|
||||
shortName = shortName.normalizeForSearch(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Pre-normalized match targets for one [TimeZoneOption]. */
|
||||
internal data class TimeZoneSearchKeys(
|
||||
val city: String,
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val shortName: String,
|
||||
)
|
||||
|
||||
private fun optionFor(
|
||||
zone: ZoneId,
|
||||
locale: Locale,
|
||||
at: Instant,
|
||||
regionOf: (String) -> String?,
|
||||
): TimeZoneOption = TimeZoneOption(
|
||||
id = zone.id,
|
||||
displayName = zone.getDisplayName(TextStyle.FULL, locale),
|
||||
shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf),
|
||||
offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60,
|
||||
)
|
||||
|
||||
/**
|
||||
* The zone abbreviation ("EDT", "CEST"), DST-correct at the resolved instant, or
|
||||
* a "GMT+05:30" form when no name exists.
|
||||
*
|
||||
* Two things make this fiddlier than it looks. First, it goes through
|
||||
* java.util.TimeZone, NOT java.time's "zzz" formatter: on Android the latter has
|
||||
* no short specific-zone names and degrades every zone to a "GMT-4" form (it
|
||||
* agrees with java.util only on desktop, which is why desktop can't catch it).
|
||||
* Second, ICU only surfaces the short name *commonly used in the display
|
||||
* locale's region* — a German-region English phone (en-DE) is shown "CEST" but
|
||||
* not "EDT", and an en-US phone the reverse. So ask in the display *language*
|
||||
* but the *zone's* region ("America/New_York" in en-US, "Europe/Berlin" in
|
||||
* en-DE) via [regionOf]. That covers almost everything; where a region still
|
||||
* yields no name (Athens in en-GR) the plain device locale sometimes does, so
|
||||
* try that next; failing both, the caller shows the offset.
|
||||
*
|
||||
* [regionOf] maps an IANA id to an ISO 3166 region and is injected because it
|
||||
* needs `android.icu`, which this pure-JVM module can't import. The default
|
||||
* (no region) leaves only the device-locale path — which is all a JVM test has
|
||||
* anyway, and enough there since desktop ICU isn't region-gated the same way.
|
||||
*/
|
||||
private fun resolveAbbreviation(
|
||||
id: String,
|
||||
dst: Boolean,
|
||||
locale: Locale,
|
||||
regionOf: (String) -> String?,
|
||||
): String {
|
||||
fun shortIn(loc: Locale): String =
|
||||
java.util.TimeZone.getTimeZone(id).getDisplayName(dst, java.util.TimeZone.SHORT, loc)
|
||||
val regional = regionOf(id)
|
||||
?.takeIf { it.length == 2 }
|
||||
?.let { shortIn(Locale(locale.language, it)) }
|
||||
if (regional != null && !regional.looksLikeOffset()) return regional
|
||||
// Either no region, or the region has no name for this zone: the device
|
||||
// locale is the next-best shot, and the offset the last resort.
|
||||
return shortIn(locale)
|
||||
}
|
||||
|
||||
/** True for the offset-style names ICU returns when a zone has no abbreviation. */
|
||||
private fun String.looksLikeOffset(): Boolean = this == "UTC" || startsWith("GMT")
|
||||
|
||||
/**
|
||||
* Every zone the JVM knows, resolved at [at]. [regionOf] (see
|
||||
* [resolveAbbreviation]) supplies the zone's region for the abbreviation.
|
||||
*
|
||||
* This is ~600 entries, each costing a localized display name plus up to two ICU
|
||||
* short-name lookups, so it is **not** cheap enough for the main thread — build
|
||||
* it off-thread once and filter the result with [filterTimeZones] rather than
|
||||
* rebuilding per keystroke.
|
||||
*
|
||||
* Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are
|
||||
* dropped: they're aliases the tz database keeps for compatibility, they'd
|
||||
* double up the real zones in the list, and none of them is what a user means
|
||||
* when they pick a place.
|
||||
*/
|
||||
fun timeZoneOptions(
|
||||
locale: Locale = Locale.getDefault(),
|
||||
at: Instant = Instant.now(),
|
||||
regionOf: (String) -> String? = { null },
|
||||
): List<TimeZoneOption> = ZoneId.getAvailableZoneIds()
|
||||
.asSequence()
|
||||
.filter { it.contains('/') && !it.startsWith("SystemV/") }
|
||||
.map { optionFor(ZoneId.of(it), locale, at, regionOf) }
|
||||
.sortedWith(compareBy({ it.region }, { it.city }))
|
||||
.toList()
|
||||
|
||||
/**
|
||||
* Resolve a single [id] the same way [timeZoneOptions] would, or null if the tz
|
||||
* database doesn't know it — for labelling one known zone without paying to
|
||||
* build the whole catalogue.
|
||||
*/
|
||||
fun timeZoneOptionOf(
|
||||
id: String,
|
||||
locale: Locale = Locale.getDefault(),
|
||||
at: Instant = Instant.now(),
|
||||
regionOf: (String) -> String? = { null },
|
||||
): TimeZoneOption? {
|
||||
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
|
||||
return optionFor(zone, locale, at, regionOf)
|
||||
}
|
||||
|
||||
/**
|
||||
* The short descriptor shown beneath the id, e.g. "CET · GMT+01:00". "UTC" reads
|
||||
* fine alone; any other offset-shaped name is normalised to our own GMT form so
|
||||
* the offset isn't stated twice in ICU's spelling and ours.
|
||||
*/
|
||||
fun zoneDescriptor(option: TimeZoneOption): String {
|
||||
val abbrev = option.shortName
|
||||
return when {
|
||||
abbrev == "UTC" -> "UTC"
|
||||
abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes)
|
||||
else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [options] matching [query], best matches first; a blank query returns the list
|
||||
* unchanged. Matching is accent- and case-insensitive and treats underscores as
|
||||
* spaces, so "sao paulo" finds "America/Sao_Paulo".
|
||||
*
|
||||
* Ranking puts a city that *starts with* the query above one that merely
|
||||
* contains it — typing "col" should reach Colombo before Turks_and_Caicos —
|
||||
* and the id is matched ahead of the localized name so a user who knows the
|
||||
* IANA id gets it first. An exact abbreviation hit ("CEST") ranks just under a
|
||||
* city prefix, so typing an abbreviation surfaces every zone that shows it
|
||||
* (all the CEST zones together); the abbreviation is the one resolved for the
|
||||
* display region, i.e. what the row actually shows.
|
||||
*/
|
||||
fun filterTimeZones(options: List<TimeZoneOption>, query: String): List<TimeZoneOption> {
|
||||
val needle = query.normalizeForSearch()
|
||||
if (needle.isEmpty()) return options
|
||||
return options
|
||||
.mapNotNull { option ->
|
||||
val keys = option.searchKeys
|
||||
val rank = when {
|
||||
keys.city.startsWith(needle) -> 0
|
||||
keys.shortName == needle -> 1
|
||||
keys.displayName.startsWith(needle) -> 2
|
||||
keys.shortName.startsWith(needle) -> 3
|
||||
keys.city.contains(needle) -> 4
|
||||
keys.id.contains(needle) -> 5
|
||||
keys.displayName.contains(needle) -> 6
|
||||
else -> return@mapNotNull null
|
||||
}
|
||||
rank to option
|
||||
}
|
||||
.sortedWith(compareBy({ it.first }, { it.second.city }))
|
||||
.map { it.second }
|
||||
}
|
||||
|
||||
/** Unicode combining marks — what NFD decomposition leaves an accent as. */
|
||||
private val COMBINING_MARKS = Regex("\\p{Mn}+")
|
||||
|
||||
/**
|
||||
* Lowercased, accent-stripped, underscores and slashes flattened to spaces, so
|
||||
* a query types the way a place is spoken rather than the way the tz database
|
||||
* spells it.
|
||||
*/
|
||||
private fun String.normalizeForSearch(): String =
|
||||
Normalizer.normalize(this, Normalizer.Form.NFD)
|
||||
.replace(COMBINING_MARKS, "")
|
||||
.replace('_', ' ')
|
||||
.replace('/', ' ')
|
||||
.lowercase(Locale.ROOT)
|
||||
.trim()
|
||||
|
||||
/** "GMT+02:00" / "GMT-05:30" / "GMT" — the offset as shown next to a zone. */
|
||||
fun formatGmtOffset(offsetMinutes: Int): String {
|
||||
if (offsetMinutes == 0) return "GMT"
|
||||
val sign = if (offsetMinutes < 0) '-' else '+'
|
||||
val abs = kotlin.math.abs(offsetMinutes)
|
||||
return "GMT%c%02d:%02d".format(sign, abs / 60, abs % 60)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package de.jeanlucmakiola.calendula.domain.reminders
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
* Pure decision layer of in-house reminder delivery (#75): instances and reminder
|
||||
* offsets in, fire instants out. No provider, no clock. See docs/ARCHITECTURE.md.
|
||||
*/
|
||||
|
||||
/** An occurrence that reminders can hang off, flattened out of `Instances`. */
|
||||
data class ReminderEventInstance(
|
||||
val eventId: Long,
|
||||
val calendarId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
val title: String,
|
||||
val location: String?,
|
||||
val isAllDay: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* One occurrence paired with one of its reminder offsets, and the instant that
|
||||
* pairing has to fire at.
|
||||
*/
|
||||
data class PlannedReminder(
|
||||
val instance: ReminderEventInstance,
|
||||
val minutes: Int,
|
||||
val alarmMillis: Long,
|
||||
) {
|
||||
/**
|
||||
* Stable identity, keying the notification tag and the snooze/dismiss
|
||||
* `PendingIntent`s. Survives reboot, re-scan and reinstall.
|
||||
*/
|
||||
val key: Long = key(instance.eventId, instance.beginMillis, minutes)
|
||||
|
||||
private companion object {
|
||||
fun key(eventId: Long, beginMillis: Long, minutes: Int): Long {
|
||||
var h = eventId * 1_000_003L
|
||||
h = (h xor beginMillis) * 31L
|
||||
return h + minutes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What one scan concluded: post these now, and wake up again at [nextAlarmMillis]. */
|
||||
data class ReminderSchedule(
|
||||
val due: List<PlannedReminder>,
|
||||
val nextAlarmMillis: Long,
|
||||
)
|
||||
|
||||
private const val MILLIS_PER_MINUTE = 60_000L
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
|
||||
/**
|
||||
* Pair every instance with each of its event's reminder offsets.
|
||||
*
|
||||
* Timed occurrences fire at `begin − minutes`. All-day ones read the offset only
|
||||
* for *which day* it means ([allDayLeadDays]) and take the hour from
|
||||
* [allDayTimeMinutes], recomposed against each occurrence's own date in [zone].
|
||||
* Duplicate offsets in [minutesByEvent] collapse.
|
||||
*/
|
||||
fun planReminders(
|
||||
instances: List<ReminderEventInstance>,
|
||||
minutesByEvent: Map<Long, List<Int>>,
|
||||
zone: ZoneId,
|
||||
allDayTimeMinutes: Int,
|
||||
): List<PlannedReminder> = instances.flatMap { instance ->
|
||||
minutesByEvent[instance.eventId].orEmpty().distinct().map { minutes ->
|
||||
PlannedReminder(
|
||||
instance = instance,
|
||||
minutes = minutes,
|
||||
alarmMillis = if (instance.isAllDay) {
|
||||
allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes)
|
||||
} else {
|
||||
instance.beginMillis - minutes * MILLIS_PER_MINUTE
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** UTC midnight of an all-day occurrence, as the calendar date it stands for. */
|
||||
private fun allDayDate(beginMillis: Long): LocalDate =
|
||||
Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
|
||||
/**
|
||||
* How many whole days before its occurrence a raw all-day offset means.
|
||||
*
|
||||
* Our own rows fold the wanted hour into the offset, so the local date of the
|
||||
* encoded instant is the answer. A plain multiple of 1440 is a foreign bare lead
|
||||
* time and taken at face value instead — unless the instant lands on the hour the
|
||||
* setting names (within [NAMED_HOUR_TOLERANCE_MINUTES], for DST drift), where the
|
||||
* encodings collide and the tie goes to our own reading.
|
||||
*
|
||||
* Also used by
|
||||
* [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes] for
|
||||
* display, so screen and notification agree.
|
||||
*/
|
||||
internal fun allDayLeadDays(
|
||||
rawMinutes: Int,
|
||||
startDate: LocalDate,
|
||||
zone: ZoneId,
|
||||
allDayTimeMinutes: Int,
|
||||
): Long {
|
||||
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
val encoded = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE).atZone(zone)
|
||||
val minutesFromNamedHour = encoded.toLocalTime().let {
|
||||
val delta = (it.hour * 60 + it.minute - allDayTimeMinutes).mod(MINUTES_PER_DAY)
|
||||
minOf(delta, MINUTES_PER_DAY - delta)
|
||||
}
|
||||
if (rawMinutes % MINUTES_PER_DAY == 0 && minutesFromNamedHour > NAMED_HOUR_TOLERANCE_MINUTES) {
|
||||
return (rawMinutes / MINUTES_PER_DAY).toLong()
|
||||
}
|
||||
return ChronoUnit.DAYS.between(encoded.toLocalDate(), startDate)
|
||||
}
|
||||
|
||||
/** DST drift a row written in the other phase carries, rounded up past Lord Howe's half hour. */
|
||||
private const val NAMED_HOUR_TOLERANCE_MINUTES = 90
|
||||
|
||||
private fun allDayAlarmMillis(
|
||||
beginMillis: Long,
|
||||
rawMinutes: Int,
|
||||
zone: ZoneId,
|
||||
allDayTimeMinutes: Int,
|
||||
): Long = allDayDate(beginMillis)
|
||||
.minusDays(allDayLeadDays(rawMinutes, allDayDate(beginMillis), zone, allDayTimeMinutes))
|
||||
.atTime(LocalTime.of(allDayTimeMinutes / 60, allDayTimeMinutes % 60))
|
||||
.atZone(zone)
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
|
||||
/**
|
||||
* Split [planned] into what is due now and when to wake up next.
|
||||
*
|
||||
* Due means the fire instant falls in `(lastFiredMillis, nowMillis]`, so a scan
|
||||
* running twice cannot post twice while a late one still catches up. Reminders
|
||||
* whose event has ended are dropped ([isStillRelevant]). [nextAlarmMillis] is
|
||||
* capped at [horizonMillis] so the lookahead window keeps rolling forward.
|
||||
*/
|
||||
fun scheduleReminders(
|
||||
planned: List<PlannedReminder>,
|
||||
lastFiredMillis: Long,
|
||||
nowMillis: Long,
|
||||
horizonMillis: Long,
|
||||
): ReminderSchedule {
|
||||
val due = planned
|
||||
.filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis }
|
||||
.filter { it.instance.isStillRelevant(nowMillis) }
|
||||
.distinctBy { it.key }
|
||||
.sortedWith(compareBy({ it.instance.beginMillis }, { it.key }))
|
||||
val nextPending = planned
|
||||
.filter { it.alarmMillis > nowMillis }
|
||||
.minOfOrNull { it.alarmMillis }
|
||||
return ReminderSchedule(
|
||||
due = due,
|
||||
nextAlarmMillis = minOf(nextPending ?: horizonMillis, horizonMillis),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Still worth showing while the occurrence has not ended. Falls back to the
|
||||
* begin time when the end is unknown (0L).
|
||||
*/
|
||||
fun ReminderEventInstance.isStillRelevant(nowMillis: Long): Boolean =
|
||||
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
|
||||
|
||||
/**
|
||||
* The watermark a scan at [nowMillis] should measure against. A first-ever scan
|
||||
* claims the present rather than replaying everything since the epoch; a
|
||||
* watermark in the future (clock moved back) is clamped so it can't silence
|
||||
* every reminder until real time catches up.
|
||||
*/
|
||||
fun reminderWatermark(lastScanMillis: Long?, nowMillis: Long): Long =
|
||||
lastScanMillis?.coerceAtMost(nowMillis) ?: nowMillis
|
||||
|
||||
/**
|
||||
* How far ahead instances must be queried: the plain lookahead plus the longest
|
||||
* reminder offset, so a "two weeks before" is planned before it comes due. The
|
||||
* stretch is capped at [MAX_REMINDER_LEAD_MILLIS] — `maxReminderMinutes` is the
|
||||
* largest row in the whole provider, and a nonsense one would otherwise make
|
||||
* every scan expand every series over years.
|
||||
*/
|
||||
fun reminderQueryHorizon(lookaheadMillis: Long, maxReminderMinutes: Int): Long =
|
||||
lookaheadMillis + (maxReminderMinutes * MILLIS_PER_MINUTE).coerceIn(0L, MAX_REMINDER_LEAD_MILLIS)
|
||||
|
||||
/** Longest reminder offset a scan stretches its query window for — one year. */
|
||||
const val MAX_REMINDER_LEAD_MILLIS = 365L * 24 * 60 * 60 * 1000
|
||||
@@ -23,8 +23,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarFadeThrough
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
@@ -33,6 +34,7 @@ import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
||||
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
||||
@@ -74,6 +76,8 @@ fun CalendarHost(
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: EventForm? = null,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
requestedEditKey: LongArray? = null,
|
||||
onEditKeyConsumed: () -> Unit = {},
|
||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||
) {
|
||||
// Wait for the persisted default view before seeding the stack, so the app
|
||||
@@ -85,6 +89,8 @@ fun CalendarHost(
|
||||
// sensible non-empty initial values, so they're ready before the first frame.
|
||||
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
|
||||
val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value
|
||||
// Whether the jump-to-today control sits in each view's top bar or the FAB (#60).
|
||||
val todayInToolbar = viewModel.todayButtonInToolbar.collectAsStateWithLifecycle().value
|
||||
|
||||
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||
mutableStateOf(listOf(defaultView))
|
||||
@@ -143,6 +149,10 @@ fun CalendarHost(
|
||||
// over Settings and survives view switches.
|
||||
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Backup & restore (#69) — hoisted like the manager, being driven by the
|
||||
// calendar list rather than by preferences. Reached from both.
|
||||
var showBackup by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Event form (v1.2 create) — same held-key pattern as the detail screen:
|
||||
// [heldCreateIso] keeps the prefill date alive through the slide-out.
|
||||
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
|
||||
@@ -172,9 +182,18 @@ fun CalendarHost(
|
||||
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
||||
// Which channel filled [importForm]: an .ics file (prompt to apply the default
|
||||
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
|
||||
var importFormSource by remember { mutableStateOf(ImportSource.File) }
|
||||
// A restore (in-app "Restore from .ics" button) always runs the full import
|
||||
// flow — picker + summary — even for a single-event file, because the intent
|
||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||
// keeps routing a single event straight into the prefilled create form.
|
||||
var importForceMany by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(requestedImportUri) {
|
||||
if (requestedImportUri != null) {
|
||||
importUri = requestedImportUri
|
||||
importForceMany = false
|
||||
onImportConsumed()
|
||||
}
|
||||
}
|
||||
@@ -184,6 +203,7 @@ fun CalendarHost(
|
||||
// reveals on top of whatever was open without extra dismissal.
|
||||
LaunchedEffect(requestedInsertForm) {
|
||||
if (requestedInsertForm != null) {
|
||||
importFormSource = ImportSource.Insert
|
||||
importForm = requestedInsertForm
|
||||
onInsertConsumed()
|
||||
}
|
||||
@@ -195,12 +215,27 @@ fun CalendarHost(
|
||||
fun dismissCoveringOverlays() {
|
||||
showSettings = false
|
||||
showCalendars = false
|
||||
showBackup = false
|
||||
detailKey = null
|
||||
editKey = null
|
||||
importUri = null
|
||||
importForm = null
|
||||
}
|
||||
|
||||
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
|
||||
// widget) opens the occurrence straight in the edit form. Drop any covering
|
||||
// overlay first — the edit overlay sits below Settings/import in the Box, so
|
||||
// without this it would open hidden underneath them. Same held-key pattern as
|
||||
// a detail-screen "Edit" tap; a saved edit just returns to the calendar.
|
||||
LaunchedEffect(requestedEditKey) {
|
||||
if (requestedEditKey != null) {
|
||||
dismissCoveringOverlays()
|
||||
heldEditKey = requestedEditKey
|
||||
editKey = requestedEditKey
|
||||
onEditKeyConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
// A home-screen widget launch asks to open a date (→ day view), open an
|
||||
// event's detail, or start a create. Handled once and cleared, mirroring
|
||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||
@@ -264,8 +299,8 @@ fun CalendarHost(
|
||||
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
||||
// so back there falls through to the system and exits the app.
|
||||
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
||||
editKey != null || showSettings || showCalendars || importUri != null ||
|
||||
importForm != null
|
||||
editKey != null || showSettings || showCalendars || showBackup ||
|
||||
importUri != null || importForm != null
|
||||
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
||||
viewStack = viewStack.dropLast(1)
|
||||
}
|
||||
@@ -274,7 +309,7 @@ fun CalendarHost(
|
||||
// Switching between the peer views (month/week/day/agenda) is lateral
|
||||
// navigation, so it fades through rather than sliding — paging *within* a
|
||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||
val viewSwitch = calendarFadeThrough()
|
||||
val viewSwitch = fadeThrough()
|
||||
AnimatedContent(
|
||||
targetState = view,
|
||||
transitionSpec = { viewSwitch },
|
||||
@@ -284,12 +319,14 @@ fun CalendarHost(
|
||||
CalendarView.Week -> WeekScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = currentView,
|
||||
@@ -301,26 +338,31 @@ fun CalendarHost(
|
||||
initialDateIso = pendingDayIso,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
)
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -355,6 +397,14 @@ fun CalendarHost(
|
||||
heldEditKey = key
|
||||
editKey = key
|
||||
},
|
||||
onDuplicate = { form ->
|
||||
// Reuse the prefilled-create overlay: a duplicate is a
|
||||
// fresh event, so it applies the default reminder like an
|
||||
// in-app new event (#52).
|
||||
importFormSource = ImportSource.Insert
|
||||
importForm = form
|
||||
detailKey = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -371,6 +421,7 @@ fun CalendarHost(
|
||||
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||
onClose = { createDateIso = null },
|
||||
onSaved = { createDateIso = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -390,6 +441,7 @@ fun CalendarHost(
|
||||
editKey = null
|
||||
detailKey = null
|
||||
},
|
||||
onManageCalendars = { showCalendars = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -403,26 +455,21 @@ fun CalendarHost(
|
||||
SettingsScreen(
|
||||
onBack = { showSettings = false },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
onOpenBackup = { showBackup = true },
|
||||
)
|
||||
}
|
||||
|
||||
// Calendar manager — slides over Settings.
|
||||
AnimatedVisibility(
|
||||
visible = showCalendars,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
CalendarsScreen(onBack = { showCalendars = false })
|
||||
}
|
||||
|
||||
// Import flow for an opened/received .ics file. A single event routes
|
||||
// into the create form (prefilled, for review); many open the picker.
|
||||
importUri?.let { uri ->
|
||||
ImportScreen(
|
||||
uri = uri,
|
||||
forceMany = importForceMany,
|
||||
onClose = { importUri = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
onOpenSingle = { form ->
|
||||
importUri = null
|
||||
importFormSource = ImportSource.File
|
||||
importForm = form
|
||||
},
|
||||
)
|
||||
@@ -431,8 +478,43 @@ fun CalendarHost(
|
||||
EventEditScreen(
|
||||
initialDateIso = null,
|
||||
initialForm = form,
|
||||
initialFormSource = importFormSource,
|
||||
onClose = { importForm = null },
|
||||
onSaved = { importForm = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
)
|
||||
}
|
||||
|
||||
// Declared last so it covers every overlay that can open it: Settings,
|
||||
// both event forms, and the .ics import picker (#76).
|
||||
AnimatedVisibility(
|
||||
visible = showCalendars,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
CalendarsScreen(
|
||||
onBack = { showCalendars = false },
|
||||
onOpenBackup = { showBackup = true },
|
||||
)
|
||||
}
|
||||
|
||||
// Backup & restore — over the manager, since the manager links into it.
|
||||
AnimatedVisibility(
|
||||
visible = showBackup,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
BackupScreen(
|
||||
onBack = { showBackup = false },
|
||||
// Restore runs the normal .ics import, and both this screen and
|
||||
// the manager that can have opened it are declared above the
|
||||
// import overlays — so both have to step aside.
|
||||
onImport = {
|
||||
importUri = it
|
||||
importForceMany = true
|
||||
showBackup = false
|
||||
showCalendars = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,4 +45,12 @@ class CalendarHostViewModel @Inject constructor(
|
||||
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
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -21,6 +22,8 @@ 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.calendars.CalendarVisibilityNoticeDialog
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||
@@ -37,6 +40,8 @@ fun RootScreen(
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
requestedEditKey: LongArray? = null,
|
||||
onEditKeyConsumed: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
@@ -46,6 +51,10 @@ fun RootScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// A launch scan already covers the app coming up with the permission, so
|
||||
// only a grant made during this session owes a re-scan.
|
||||
val grantedAtLaunch = remember { hasPermission }
|
||||
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle) {
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
@@ -76,6 +85,19 @@ fun RootScreen(
|
||||
// frame instead of flashing the wrong screen.
|
||||
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||
// One-time explainer for the switch to the device's own calendar
|
||||
// visibility (#75), armed by the reconciler.
|
||||
val visibilityNotice: CalendarVisibilityNoticeViewModel = hiltViewModel()
|
||||
val noticePending by visibilityNotice.pending.collectAsStateWithLifecycle()
|
||||
// Runs on entry however the permission was granted, including via
|
||||
// Android's app-settings screen (caught by the ON_RESUME above).
|
||||
LaunchedEffect(Unit) {
|
||||
visibilityNotice.reconcile()
|
||||
if (!grantedAtLaunch) reminderOnboarding.rearmAfterGrant()
|
||||
}
|
||||
if (onboardingDone == true && noticePending) {
|
||||
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
|
||||
}
|
||||
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
||||
when (done) {
|
||||
true -> CalendarHost(
|
||||
@@ -88,6 +110,8 @@ fun RootScreen(
|
||||
onImportConsumed = onImportConsumed,
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
onInsertConsumed = onInsertConsumed,
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = onEditKeyConsumed,
|
||||
)
|
||||
false -> ReminderOnboardingScreen(
|
||||
onFinished = reminderOnboarding::finish,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -87,24 +86,37 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
|
||||
|
||||
/**
|
||||
* The concrete span the range covers, starting at [start] through [end]
|
||||
* (inclusive), for a human-readable header:
|
||||
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
|
||||
* (inclusive), for a human-readable header. Both ends use the same day-month
|
||||
* order so a span never mixes "15 Jul" with "Aug 13, 2026":
|
||||
* - [AgendaRange.Day] → a single date ("27 Jun 2026")
|
||||
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026")
|
||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026"), with the start's
|
||||
* year shown too only when it differs from the end's.
|
||||
*
|
||||
* [monthAsSpan] puts [AgendaRange.ThisMonth] on the "start – end" form as well.
|
||||
* The agenda's own header browses the whole month, so the month name is right
|
||||
* there; the range picker previews the window an option opens *today*, which for
|
||||
* "This month" is only the rest of it.
|
||||
*/
|
||||
fun agendaRangeWindowSummary(
|
||||
range: AgendaRange,
|
||||
start: LocalDate,
|
||||
end: LocalDate,
|
||||
locale: Locale,
|
||||
monthAsSpan: Boolean = false,
|
||||
): String {
|
||||
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 medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
return when (range) {
|
||||
AgendaRange.Day -> medium.format(javaStart)
|
||||
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
|
||||
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} – ${medium.format(javaEnd)}"
|
||||
val dayMonth = localizedDateFormatter(locale, "dMMM")
|
||||
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
|
||||
return when {
|
||||
range == AgendaRange.Day -> dayMonthYear.format(javaStart)
|
||||
range == AgendaRange.ThisMonth && !monthAsSpan ->
|
||||
localizedDateFormatter(locale, "LLLLy").format(javaStart)
|
||||
else -> {
|
||||
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
|
||||
"${startFmt.format(javaStart)} – ${dayMonthYear.format(javaEnd)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
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
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Coffee
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
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.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.util.Locale
|
||||
|
||||
// The agenda's row vocabulary, split out of AgendaScreen so the month view's
|
||||
// split style can list a day with exactly the same visual language instead of
|
||||
// growing a parallel set of event rows.
|
||||
|
||||
@Composable
|
||||
internal fun AgendaDayHeader(
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpenDay(date) },
|
||||
) {
|
||||
Text(
|
||||
text = agendaDayLabel(date, today),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = if (date == today) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A card standing in for a day with no events — the same coffee-cup motif as the
|
||||
* agenda's full-screen empty state, boxed into a card so the day keeps a visible slot
|
||||
* rather than a bare header. Used for an anchored, event-less today (#35) and for
|
||||
* an empty selected day in the month view's split style.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AgendaEmptyDayRow(text: String, onClick: () -> Unit) {
|
||||
Card(
|
||||
// Match a single event row's resting corner radius (floret groupedShape).
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 18.dp, horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Coffee,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AgendaEventRow(
|
||||
event: EventInstance,
|
||||
day: LocalDate,
|
||||
zone: TimeZone,
|
||||
position: Position,
|
||||
dimmed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = title,
|
||||
summary = agendaTimeSummary(event, day, zone),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
leading = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 6.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(eventFill(event.color, dark, soften)),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
||||
@Composable
|
||||
internal fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
||||
val relative = when (date) {
|
||||
today -> stringResource(R.string.agenda_header_today)
|
||||
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
||||
else -> null
|
||||
}
|
||||
val formatted = formatAgendaDate(date)
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
/**
|
||||
* Time line under the title: "09:00 – 10:00 · Location", "All day", etc.
|
||||
*
|
||||
* A multi-day event shows only the part relevant to [day], spelled out so each
|
||||
* day reads on its own: its first day names the start ("Starts 14:00"), its last
|
||||
* day the end ("Ends 10:00"), and any whole day in between reads as "All day".
|
||||
* An all-day multi-day event is simply "All day" on every day it covers.
|
||||
*/
|
||||
@Composable
|
||||
internal fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String {
|
||||
val is24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
|
||||
val time = when (val label = agendaTimeLabel(event, day, zone)) {
|
||||
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
|
||||
is AgendaTimeLabel.Starts -> stringResource(
|
||||
R.string.agenda_span_starts,
|
||||
formatTime(label.start, zone, is24Hour, locale),
|
||||
)
|
||||
is AgendaTimeLabel.Ends -> stringResource(
|
||||
R.string.agenda_span_ends,
|
||||
formatTime(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 = 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)
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
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.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -15,23 +12,21 @@ 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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Coffee
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
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.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
@@ -45,8 +40,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -59,52 +52,48 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
||||
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.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
// No file-level zone constant here on purpose: it would be fixed for the process
|
||||
// lifetime and drift from the zone AgendaViewModel groups in after a device
|
||||
// time-zone change. The zone travels on AgendaUiState.Success instead.
|
||||
|
||||
@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,
|
||||
viewModel: AgendaViewModel = hiltViewModel(),
|
||||
) {
|
||||
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)
|
||||
@@ -147,12 +136,14 @@ fun AgendaScreen(
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = viewModel::goToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnToday,
|
||||
todayVisible = !isOnToday && !todayInToolbar,
|
||||
todayText = stringResource(R.string.agenda_today_action),
|
||||
onToday = viewModel::goToToday,
|
||||
onCreate = { onCreateEvent(anchor, null) },
|
||||
@@ -169,9 +160,11 @@ fun AgendaScreen(
|
||||
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 = 16.dp, top = 8.dp, bottom = 8.dp),
|
||||
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
AgendaRangeBanner(
|
||||
range = s.range,
|
||||
@@ -189,8 +182,10 @@ fun AgendaScreen(
|
||||
AgendaContent(
|
||||
state = state,
|
||||
pastDisplay = pastDisplay,
|
||||
showToday = showToday,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
@@ -204,6 +199,7 @@ fun AgendaScreen(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
description = stringResource(R.string.agenda_range_override_hint),
|
||||
selected = successState?.range ?: AgendaRange.Month,
|
||||
weekStart = weekStart,
|
||||
onSelect = viewModel::setRangeOverride,
|
||||
onDismiss = { showRangePicker = false },
|
||||
)
|
||||
@@ -211,9 +207,11 @@ fun AgendaScreen(
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact tonal pill showing the agenda's current range. Tapping it opens the
|
||||
* range picker as a session-only override. Filled with the primary container
|
||||
* while an override is active, so the temporary state is obvious.
|
||||
* The agenda's current range, tapped to open the range picker as a session-only
|
||||
* override. Shares the top-bar view switcher's button shape so the two read as a
|
||||
* family, but stays low-emphasis — a subtle neutral surface tint rather than the
|
||||
* switcher's secondary container, so it doesn't compete. Fills with the primary
|
||||
* container only while an override is active, to make that temporary state clear.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangePill(
|
||||
@@ -222,43 +220,34 @@ private fun AgendaRangePill(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val container = if (isOverride) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
val content = if (isOverride) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Surface(
|
||||
color = container,
|
||||
contentColor = content,
|
||||
shape = RoundedCornerShape(50),
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
FilledTonalButton(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = if (isOverride) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
},
|
||||
contentColor = if (isOverride) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DateRange,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = agendaRangeLabel(range),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = agendaRangeLabel(range),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A header naming the concrete window currently shown, e.g. "Showing all events
|
||||
* for · Today, 27 Jun 2026" / "This week, 27 Jun – 3 Jul" / "This month, June 2026".
|
||||
* 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
|
||||
* lives on the selector button beside it, so it isn't repeated here.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangeBanner(
|
||||
@@ -275,8 +264,10 @@ private fun AgendaRangeBanner(
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Just the concrete dates — the range's name ("Next 30 days") already
|
||||
// sits on the selector button to the right, so repeating it here is noise.
|
||||
Text(
|
||||
text = "${agendaRangeLabel(range)}, $window",
|
||||
text = window,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
@@ -287,8 +278,10 @@ private fun AgendaRangeBanner(
|
||||
private fun AgendaContent(
|
||||
state: AgendaUiState,
|
||||
pastDisplay: PastEventDisplay,
|
||||
showToday: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (state) {
|
||||
@@ -301,7 +294,7 @@ private fun AgendaContent(
|
||||
// Hiding drops finished events — and any day they leave empty; dimming
|
||||
// keeps them but fades the row. Recomputed each minute so events fall
|
||||
// away (or fade) as they end while the screen stays open.
|
||||
val days = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
state.days.mapNotNull { day ->
|
||||
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||
@@ -309,15 +302,25 @@ private fun AgendaContent(
|
||||
} else {
|
||||
state.days
|
||||
}
|
||||
// Anchor today with a "nothing left today" placeholder — but only when
|
||||
// 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,
|
||||
)
|
||||
if (days.isEmpty()) {
|
||||
AgendaEmpty(modifier)
|
||||
} else {
|
||||
AgendaList(
|
||||
days = days,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||
now = now,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -330,9 +333,11 @@ private fun AgendaContent(
|
||||
private fun AgendaList(
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
dimPast: Boolean,
|
||||
now: Instant,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
@@ -342,72 +347,40 @@ private fun AgendaList(
|
||||
) {
|
||||
days.forEach { day ->
|
||||
stickyHeader(key = "header-${day.date}") {
|
||||
AgendaDayHeader(date = day.date, today = today)
|
||||
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
||||
}
|
||||
itemsIndexed(
|
||||
items = day.events,
|
||||
key = { _, event -> event.instanceId },
|
||||
) { index, event ->
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = calendarAnimateItem(),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = agendaDayLabel(date, today),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = if (date == today) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaEventRow(
|
||||
event: EventInstance,
|
||||
position: Position,
|
||||
dimmed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = title,
|
||||
summary = agendaTimeSummary(event),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
leading = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 6.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(pastelize(event.color, dark)),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
@@ -437,6 +410,8 @@ private fun AgendaTopBar(
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
@@ -455,6 +430,7 @@ private fun AgendaTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TodayAction(show = showTodayButton, onToday = onToday)
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
@@ -474,42 +450,3 @@ private fun AgendaTopBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
||||
@Composable
|
||||
private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
||||
val relative = when (date) {
|
||||
today -> stringResource(R.string.agenda_header_today)
|
||||
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
||||
else -> null
|
||||
}
|
||||
val formatted = formatAgendaDate(date)
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
/** Time line under the title: "09:00 – 10:00 · Location", "All day", etc. */
|
||||
@Composable
|
||||
private fun agendaTimeSummary(event: EventInstance): String {
|
||||
val time = if (event.isAllDay) {
|
||||
stringResource(R.string.event_detail_all_day)
|
||||
} else {
|
||||
val is24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
"${formatTime(event.start, is24Hour, locale)} – ${formatTime(event.end, is24Hour, locale)}"
|
||||
}
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
private fun formatTime(instant: Instant, 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 = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
return "$weekday, ${date.day}. $monthName ${date.year}"
|
||||
}
|
||||
|
||||
@@ -2,9 +2,43 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* What an agenda row's time line should convey for an event on a given day —
|
||||
* the part of a multi-day span that [day] falls in. Pure and shared so the
|
||||
* agenda screen and the agenda widget label multi-day events identically; each
|
||||
* surface only formats the instants into its own locale/24h string.
|
||||
*/
|
||||
sealed interface AgendaTimeLabel {
|
||||
/** An all-day event, or a whole in-between day of a multi-day span. */
|
||||
data object AllDay : AgendaTimeLabel
|
||||
/** The first day of a multi-day timed event: when it begins. */
|
||||
data class Starts(val start: Instant) : AgendaTimeLabel
|
||||
/** The last day of a multi-day timed event: when it ends. */
|
||||
data class Ends(val end: Instant) : AgendaTimeLabel
|
||||
/** A single-day timed event: its start–end range. */
|
||||
data class Range(val start: Instant, val end: Instant) : AgendaTimeLabel
|
||||
}
|
||||
|
||||
/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */
|
||||
fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel {
|
||||
if (event.isAllDay) return AgendaTimeLabel.AllDay
|
||||
val firstDay = event.spanFirstDay(zone)
|
||||
val lastDay = event.spanLastDay(zone)
|
||||
return when {
|
||||
firstDay == lastDay -> AgendaTimeLabel.Range(event.start, event.end)
|
||||
day <= firstDay -> AgendaTimeLabel.Starts(event.start)
|
||||
day >= lastDay -> AgendaTimeLabel.Ends(event.end)
|
||||
else -> AgendaTimeLabel.AllDay // a full in-between day
|
||||
}
|
||||
}
|
||||
|
||||
/** One calendar day with at least one event, for the agenda list. */
|
||||
data class AgendaDay(
|
||||
@@ -15,31 +49,69 @@ data class AgendaDay(
|
||||
|
||||
/**
|
||||
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
|
||||
* actually carry events). An event that began before [anchor] (ongoing or
|
||||
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a
|
||||
* day, all-day events sort first, then ascending by start time, then title.
|
||||
* actually carry events). A multi-day event surfaces on *every* day it spans,
|
||||
* not just its first — clamped to [[anchor], [windowEnd]] so an event that began
|
||||
* before the window (ongoing) still lists from the anchor day, and one running
|
||||
* past the window stops at the last visible day. Within a day, all-day events
|
||||
* sort first, then ascending by start time, then title.
|
||||
*
|
||||
* Shared by the Agenda screen and the agenda home-screen widget so both group
|
||||
* and order identically.
|
||||
*/
|
||||
fun groupAgendaDays(
|
||||
anchor: LocalDate,
|
||||
windowEnd: LocalDate,
|
||||
instances: List<EventInstance>,
|
||||
zone: TimeZone,
|
||||
): List<AgendaDay> =
|
||||
instances
|
||||
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) }
|
||||
.toSortedMap()
|
||||
.map { (date, dayEvents) ->
|
||||
AgendaDay(
|
||||
date = date,
|
||||
events = dayEvents.sortedWith(
|
||||
compareByDescending<EventInstance> { it.isAllDay }
|
||||
.thenBy { it.start }
|
||||
.thenBy { it.title },
|
||||
),
|
||||
)
|
||||
): List<AgendaDay> {
|
||||
val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>()
|
||||
for (instance in instances) {
|
||||
val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor)
|
||||
val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd)
|
||||
// Skip instances that don't actually occupy any day in [[anchor], [windowEnd]].
|
||||
// The provider returns an event whenever its instant span overlaps the query
|
||||
// window, but all-day events live at UTC midnights with an exclusive end: east
|
||||
// of UTC that end dips just past local midnight, so *yesterday's* all-day event
|
||||
// overlaps today's window start and comes back even though its true last day
|
||||
// (resolved in UTC) is before the anchor. Clamping it up to the anchor would
|
||||
// surface it under "today" (issue #65); drop it instead. The symmetric case —
|
||||
// a next-day all-day event overlapping the window's last instant — drops too.
|
||||
if (lastDay < firstDay) continue
|
||||
var day = firstDay
|
||||
while (day <= lastDay) {
|
||||
byDay.getOrPut(day) { mutableListOf() }.add(instance)
|
||||
day = day.plus(1, DateTimeUnit.DAY)
|
||||
}
|
||||
}
|
||||
return byDay.map { (date, dayEvents) ->
|
||||
AgendaDay(
|
||||
date = date,
|
||||
events = dayEvents.sortedWith(
|
||||
compareByDescending<EventInstance> { it.isAllDay }
|
||||
.thenBy { it.start }
|
||||
.thenBy { it.title },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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.none { it.date == today }) {
|
||||
listOf(AgendaDay(today, emptyList())) + days
|
||||
} else {
|
||||
days
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
||||
@@ -61,5 +133,12 @@ sealed interface AgendaUiState {
|
||||
val rangeEnd: LocalDate,
|
||||
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
|
||||
val showRangeBar: Boolean,
|
||||
/**
|
||||
* The zone [days] were grouped in. Carried in the state rather than
|
||||
* re-read by the screen so labelling and grouping cannot disagree: an
|
||||
* event's "Starts …/Ends …/All day" line is only correct relative to the
|
||||
* same zone that decided which day it was filed under.
|
||||
*/
|
||||
val zone: TimeZone,
|
||||
) : AgendaUiState
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ 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.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
@@ -20,13 +20,11 @@ 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 java.util.Locale
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
@@ -50,9 +48,12 @@ class AgendaViewModel @Inject constructor(
|
||||
settingsPrefs.agendaShowRangeBar,
|
||||
) { range, showBar -> AgendaSettings(range, showBar) }
|
||||
|
||||
// First day of the week, for the calendar-aligned "this week" range.
|
||||
private val weekStartDay = settingsPrefs.weekStart
|
||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
||||
/**
|
||||
* First day of the week, for the calendar-aligned "this week" range. Public
|
||||
* because the range picker resolves each option to real dates, which needs
|
||||
* the same week start the window is built from.
|
||||
*/
|
||||
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||
|
||||
/**
|
||||
* How to treat events that already ended today (show / dim / hide). A display
|
||||
@@ -66,6 +67,19 @@ class AgendaViewModel @Inject constructor(
|
||||
initialValue = PastEventDisplay.SHOW,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether to keep today anchored at the top with a "nothing left today"
|
||||
* placeholder even once it has no remaining events (#35). A display concern
|
||||
* applied in the composition (after past-event filtering), so it rides
|
||||
* alongside the data state like [pastEventDisplay] rather than re-querying.
|
||||
*/
|
||||
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = true,
|
||||
)
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
|
||||
private val todayDate: LocalDate
|
||||
@@ -80,7 +94,7 @@ class AgendaViewModel @Inject constructor(
|
||||
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
||||
|
||||
val state: StateFlow<AgendaUiState> =
|
||||
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart ->
|
||||
combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart ->
|
||||
AgendaParams(
|
||||
anchor = anchor,
|
||||
range = override ?: settings.range,
|
||||
@@ -146,11 +160,11 @@ class AgendaViewModel @Inject constructor(
|
||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val anchor = params.anchor
|
||||
val days = groupAgendaDays(anchor, instances, zone)
|
||||
val rangeEnd = anchor.plus(
|
||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
|
||||
return AgendaUiState.Success(
|
||||
anchor = anchor,
|
||||
today = todayDate,
|
||||
@@ -159,6 +173,7 @@ class AgendaViewModel @Inject constructor(
|
||||
rangeIsOverride = params.rangeIsOverride,
|
||||
rangeEnd = rangeEnd,
|
||||
showRangeBar = params.showRangeBar,
|
||||
zone = zone,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.FileUpload
|
||||
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.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.LeadingAvatar
|
||||
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.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import java.time.LocalDate
|
||||
|
||||
// SAF mime filter for the restore picker. Source apps hand `.ics` files out
|
||||
// under several mimes, so accept the common set.
|
||||
private val RESTORE_MIME_TYPES = arrayOf(
|
||||
"text/calendar",
|
||||
"application/octet-stream",
|
||||
"text/plain",
|
||||
)
|
||||
|
||||
/**
|
||||
* Backup & restore (#69): `.ics` export and restore for local calendars, with
|
||||
* optional automatic backup. Shares [CalendarsViewModel] with the manager.
|
||||
*
|
||||
* A full-screen destination hoisted in `CalendarHost`; [onBack] pops it,
|
||||
* [onImport] hands a picked file to the app's normal .ics import flow.
|
||||
*/
|
||||
@Composable
|
||||
fun BackupScreen(
|
||||
onBack: () -> Unit,
|
||||
onImport: (Uri) -> Unit,
|
||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||
) {
|
||||
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(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri -> uri?.let { viewModel.exportBackup(it, null) } }
|
||||
var showExportPicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Restore runs the picked file through the normal .ics import flow.
|
||||
val openBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
) { uri -> uri?.let(onImport) }
|
||||
|
||||
// The VM persists the write grant so background runs can keep writing.
|
||||
val pickFolder = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri -> uri?.let(viewModel::setAutoBackupFolder) }
|
||||
var showInterval by remember { mutableStateOf(false) }
|
||||
|
||||
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||
LaunchedEffect(backupResult) {
|
||||
when (val r = backupResult) {
|
||||
is BackupResult.Success -> {
|
||||
snackbarHostState.showSnackbar(
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
|
||||
),
|
||||
)
|
||||
viewModel.consumeBackupResult()
|
||||
}
|
||||
BackupResult.Failure -> {
|
||||
snackbarHostState.showSnackbar(backupFailedText)
|
||||
viewModel.consumeBackupResult()
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_backup),
|
||||
onBack = onBack,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
predictiveBack = true,
|
||||
) {
|
||||
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
|
||||
}
|
||||
},
|
||||
)
|
||||
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) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showExportPicker) {
|
||||
ExportCalendarPicker(
|
||||
calendars = exportable,
|
||||
onExport = viewModel::exportBackup,
|
||||
onDismiss = { showExportPicker = false },
|
||||
)
|
||||
}
|
||||
if (showInterval) {
|
||||
BackupIntervalDialog(
|
||||
currentMinutes = autoBackup.intervalMinutes,
|
||||
onConfirm = viewModel::setAutoBackupIntervalMinutes,
|
||||
onDismiss = { showInterval = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* the picked file with the chosen calendar ids.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExportCalendarPicker(
|
||||
calendars: List<CalendarSource>,
|
||||
onExport: (Uri, Set<Long>?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Deliberately not keyed on [calendars]: that list is observer-driven, so
|
||||
// keying it would reset the user's de-selections on every provider re-emit.
|
||||
var selected by rememberSaveable(
|
||||
stateSaver = listSaver(
|
||||
save = { it.toList() },
|
||||
restore = { it.toSet() },
|
||||
),
|
||||
) {
|
||||
mutableStateOf(calendars.map { it.id }.toSet())
|
||||
}
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
onExport(uri, selected)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.calendars_export_title),
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
HintText(stringResource(R.string.calendars_export_hint))
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id in selected
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
summary = calendar.description,
|
||||
position = positionOf(index, calendars.size),
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { checked ->
|
||||
selected = if (checked) selected + calendar.id else selected - calendar.id
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
selected = if (isSelected) selected - calendar.id else selected + calendar.id
|
||||
},
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
},
|
||||
enabled = selected.isNotEmpty(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.calendars_export_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
||||
@Composable
|
||||
private fun rememberFolderName(uriString: String?): String? {
|
||||
val context = LocalContext.current
|
||||
return remember(uriString) {
|
||||
uriString?.let {
|
||||
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
|
||||
@Composable
|
||||
private fun backupIntervalLabel(minutes: Long): String {
|
||||
val duration = when {
|
||||
minutes % MINUTES_PER_WEEK == 0L ->
|
||||
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
|
||||
minutes % MINUTES_PER_DAY == 0L ->
|
||||
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
|
||||
minutes % 60L == 0L ->
|
||||
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
|
||||
else ->
|
||||
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
|
||||
}
|
||||
return stringResource(R.string.calendars_auto_backup_every, duration)
|
||||
}
|
||||
|
||||
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
|
||||
@Composable
|
||||
private fun backupStatusText(status: BackupStatus): String {
|
||||
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
||||
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 {
|
||||
stringResource(R.string.calendars_auto_backup_status_failed, relative)
|
||||
}
|
||||
}
|
||||
|
||||
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
|
||||
@Composable
|
||||
private fun BackupIntervalDialog(
|
||||
currentMinutes: Long,
|
||||
onConfirm: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Pick the largest unit the current value divides into.
|
||||
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
|
||||
val units = stringArrayResource(R.array.backup_interval_units).toList()
|
||||
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
|
||||
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
|
||||
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
|
||||
text = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
|
||||
Spacer(Modifier.width(12.dp))
|
||||
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_auto_backup_interval_min),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
|
||||
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
|
||||
onDismiss()
|
||||
}) { Text(stringResource(R.string.reminder_custom_set)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private const val MINUTES_PER_DAY = 1_440L
|
||||
private const val MINUTES_PER_WEEK = 10_080L
|
||||
@@ -0,0 +1,71 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The one-time notice that Calendula now follows the device's per-calendar
|
||||
* visibility (#75), armed by `CalendarVisibilityReconciler`. The app does not
|
||||
* switch those calendars back on — that would un-hide them everywhere else too.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CalendarVisibilityNoticeViewModel @Inject constructor(
|
||||
private val prefs: CalendarPrefs,
|
||||
private val reconciler: CalendarVisibilityReconciler,
|
||||
) : ViewModel() {
|
||||
|
||||
/**
|
||||
* Reconcile whenever the app comes up with the calendar permission held,
|
||||
* rather than off one grant route: a permission granted on Android's
|
||||
* app-settings screen never reaches the permission screen's callback.
|
||||
*/
|
||||
fun reconcile() {
|
||||
viewModelScope.launch { reconciler.run() }
|
||||
}
|
||||
|
||||
val pending: StateFlow<Boolean> = prefs.visibilityNoticePending
|
||||
.map { it == true }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
fun dismiss() {
|
||||
viewModelScope.launch { prefs.setVisibilityNoticePending(false) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain informational dialog — one acknowledgement, nothing to decide. */
|
||||
@Composable
|
||||
fun CalendarVisibilityNoticeDialog(onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(Icons.Default.VisibilityOff, contentDescription = null) },
|
||||
title = { Text(stringResource(R.string.calendars_visibility_notice_title)) },
|
||||
text = { Text(stringResource(R.string.calendars_visibility_notice_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.dialog_ok))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -4,11 +4,7 @@ import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -24,23 +20,21 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
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.Cloud
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
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.Schedule
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -71,64 +65,63 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.CalendarStateLabel
|
||||
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
||||
import de.jeanlucmakiola.calendula.domain.isNotSynced
|
||||
import de.jeanlucmakiola.calendula.domain.orderedForManager
|
||||
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.DialogAmountField
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||
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.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
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.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import java.time.LocalDate
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||
|
||||
/**
|
||||
* Calendar manager (reached from Settings). Lists the app's own device-only
|
||||
* calendars with create / rename / recolor / delete (via a full-screen editor),
|
||||
* and lists synced calendars read-only with a per-account "manage in the source
|
||||
* app" deep-link — the app never touches a synced calendar's server. A
|
||||
* full-screen destination; [onBack] pops it.
|
||||
* calendars with create / rename / recolor / delete, and synced calendars
|
||||
* read-only with a per-account "manage in the source app" deep-link.
|
||||
*
|
||||
* Export/import lives in its own Settings entry ([BackupScreen], #69); this
|
||||
* screen only points at it. [onBack] pops the destination.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarsScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenBackup: () -> Unit,
|
||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
|
||||
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
|
||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||
|
||||
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
||||
// [editorSession] bumps on every open so the editor's field state resets for
|
||||
@@ -144,6 +137,7 @@ fun CalendarsScreen(
|
||||
initialName = editing?.displayName.orEmpty(),
|
||||
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
||||
initialDescription = editing?.description.orEmpty(),
|
||||
deleteLocked = editing != null && editing.id in deleteLockedIds,
|
||||
onSave = { name, color, description ->
|
||||
val id = editorId
|
||||
if (id == null || id == NEW_CALENDAR_ID) {
|
||||
@@ -163,21 +157,14 @@ fun CalendarsScreen(
|
||||
CalendarsList(
|
||||
local = calendars.filter { it.isLocal },
|
||||
synced = calendars.filterNot { it.isLocal },
|
||||
disabledIds = disabledIds,
|
||||
error = error,
|
||||
onConsumeError = viewModel::consumeError,
|
||||
backupResult = backupResult,
|
||||
onExportBackup = viewModel::exportBackup,
|
||||
onConsumeBackupResult = viewModel::consumeBackupResult,
|
||||
autoBackup = autoBackup,
|
||||
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
|
||||
onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes,
|
||||
onSetAutoBackupFolder = viewModel::setAutoBackupFolder,
|
||||
onOpenBackup = onOpenBackup,
|
||||
onBack = onBack,
|
||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||
onSetDisabled = viewModel::setDisabled,
|
||||
onSetAccountDisabled = viewModel::setAccountDisabled,
|
||||
onSetVisible = viewModel::setCalendarVisible,
|
||||
onSetAccountVisible = viewModel::setAccountVisible,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -186,27 +173,20 @@ fun CalendarsScreen(
|
||||
private fun CalendarsList(
|
||||
local: List<CalendarSource>,
|
||||
synced: List<CalendarSource>,
|
||||
disabledIds: Set<Long>,
|
||||
error: Boolean,
|
||||
onConsumeError: () -> Unit,
|
||||
backupResult: BackupResult?,
|
||||
onExportBackup: (android.net.Uri) -> Unit,
|
||||
onConsumeBackupResult: () -> Unit,
|
||||
autoBackup: AutoBackupUiState,
|
||||
onSetAutoBackupEnabled: (Boolean) -> Unit,
|
||||
onSetAutoBackupInterval: (Long) -> Unit,
|
||||
onSetAutoBackupFolder: (android.net.Uri) -> Unit,
|
||||
onOpenBackup: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
onEdit: (CalendarSource) -> Unit,
|
||||
onSetDisabled: (Long, Boolean) -> Unit,
|
||||
onSetAccountDisabled: (Collection<Long>, Boolean) -> Unit,
|
||||
onSetVisible: (Long, Boolean) -> Unit,
|
||||
onSetAccountVisible: (Collection<Long>, Boolean) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
// Accounts the user has folded shut; empty = all expanded (keeps every
|
||||
// calendar visible by default, the section is collapsible for tidiness).
|
||||
var collapsedAccounts by remember { mutableStateOf(emptySet<String>()) }
|
||||
var collapsedAccounts by remember { mutableStateOf(emptySet<AccountKey>()) }
|
||||
var localExpanded by remember { mutableStateOf(true) }
|
||||
val writeErrorText = stringResource(R.string.calendars_write_error)
|
||||
|
||||
@@ -217,50 +197,19 @@ private fun CalendarsList(
|
||||
}
|
||||
}
|
||||
|
||||
// SAF "create document" target for the backup file. The picked Uri is handed
|
||||
// to the VM to stream the .ics into.
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri -> uri?.let(onExportBackup) }
|
||||
|
||||
// SAF folder picker for the automatic-backup destination; the VM persists the
|
||||
// write grant so background runs can keep writing to it.
|
||||
val pickFolder = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri -> uri?.let(onSetAutoBackupFolder) }
|
||||
var showInterval by remember { mutableStateOf(false) }
|
||||
|
||||
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||
LaunchedEffect(backupResult) {
|
||||
when (val r = backupResult) {
|
||||
is BackupResult.Success -> {
|
||||
snackbarHostState.showSnackbar(
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
|
||||
),
|
||||
)
|
||||
onConsumeBackupResult()
|
||||
}
|
||||
BackupResult.Failure -> {
|
||||
snackbarHostState.showSnackbar(backupFailedText)
|
||||
onConsumeBackupResult()
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.calendars_title),
|
||||
onBack = onBack,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
predictiveBack = true,
|
||||
) {
|
||||
// What the per-calendar / per-account switches below actually do.
|
||||
HintText(stringResource(R.string.calendars_disable_hint))
|
||||
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;
|
||||
// tapping a calendar row opens its editor.
|
||||
val localDisabled = local.isNotEmpty() && local.all { it.id in disabledIds }
|
||||
val localDisabled = local.isNotEmpty() && local.none { it.isVisibleInSystem }
|
||||
CalendarGroup(
|
||||
title = stringResource(R.string.calendars_local_header),
|
||||
expanded = localExpanded,
|
||||
@@ -272,17 +221,17 @@ private fun CalendarsList(
|
||||
onManage = onAdd,
|
||||
onToggleExpand = { localExpanded = !localExpanded },
|
||||
showToggleAll = local.isNotEmpty(),
|
||||
allEnabled = local.none { it.id in disabledIds },
|
||||
onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) },
|
||||
allEnabled = local.all { it.isVisibleInSystem },
|
||||
onToggleAll = { enabled -> onSetAccountVisible(local.map { it.id }, enabled) },
|
||||
) {
|
||||
if (local.isEmpty()) {
|
||||
HintText(stringResource(R.string.calendars_local_empty))
|
||||
} else {
|
||||
local.forEachIndexed { index, calendar ->
|
||||
val disabled = calendar.id in disabledIds
|
||||
val disabled = !calendar.isVisibleInSystem
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
summary = calendar.description,
|
||||
summary = calendarRowSummary(calendar),
|
||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
dimmed = disabled,
|
||||
@@ -291,7 +240,7 @@ private fun CalendarsList(
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
enabled = !disabled,
|
||||
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
||||
)
|
||||
},
|
||||
onClick = { onEdit(calendar) },
|
||||
@@ -300,49 +249,22 @@ private fun CalendarsList(
|
||||
}
|
||||
}
|
||||
|
||||
// Backup — local calendars have no sync, so a .ics export is their only
|
||||
// safety net. Offered only when there is something to back up.
|
||||
if (local.isNotEmpty()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
// One connected card: the one-time export on top, then automatic
|
||||
// backup (and its folder/interval rows when on).
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_backup_action),
|
||||
position = Position.Top,
|
||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||
onClick = {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
},
|
||||
)
|
||||
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 = onSetAutoBackupEnabled)
|
||||
},
|
||||
onClick = { onSetAutoBackupEnabled(!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) } },
|
||||
// Pointer to the Backup entry (#69), so it stays findable from here.
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_backup),
|
||||
summary = stringResource(R.string.settings_backup_subtitle),
|
||||
position = Position.Alone,
|
||||
leading = { LeadingAvatar(Icons.Default.Backup) },
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { showInterval = true },
|
||||
)
|
||||
HintText(backupStatusText(autoBackup.status))
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = onOpenBackup,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
@@ -352,14 +274,19 @@ private fun CalendarsList(
|
||||
SectionHeader(stringResource(R.string.calendars_synced_header))
|
||||
HintText(stringResource(R.string.calendars_synced_hint))
|
||||
synced
|
||||
.groupBy { it.accountName.ifBlank { it.accountType } }
|
||||
.forEach { (account, cals) ->
|
||||
val expanded = account !in collapsedAccounts
|
||||
val accountType = cals.first().accountType
|
||||
val accountDisabled = cals.all { it.id in disabledIds }
|
||||
.groupByAccount()
|
||||
.forEach { group ->
|
||||
val cals = group.calendars
|
||||
val expanded = group.key !in collapsedAccounts
|
||||
val accountType = group.accountType
|
||||
// A non-syncing calendar has no switch, so it neither counts
|
||||
// towards "the whole account is off" nor moves with toggle-all.
|
||||
val switchable = cals.filter { it.hasVisibilitySwitch }
|
||||
val accountDisabled = switchable.isNotEmpty() &&
|
||||
switchable.none { it.isVisibleInSystem }
|
||||
Spacer(Modifier.height(16.dp))
|
||||
CalendarGroup(
|
||||
title = account,
|
||||
title = accountGroupTitle(group),
|
||||
expanded = expanded,
|
||||
bodyHasRows = true,
|
||||
headerDisabled = accountDisabled,
|
||||
@@ -371,29 +298,41 @@ private fun CalendarsList(
|
||||
},
|
||||
onToggleExpand = {
|
||||
collapsedAccounts = if (expanded) {
|
||||
collapsedAccounts + account
|
||||
collapsedAccounts + group.key
|
||||
} else {
|
||||
collapsedAccounts - account
|
||||
collapsedAccounts - group.key
|
||||
}
|
||||
},
|
||||
showToggleAll = true,
|
||||
allEnabled = cals.none { it.id in disabledIds },
|
||||
onToggleAll = { enabled -> onSetAccountDisabled(cals.map { it.id }, !enabled) },
|
||||
showToggleAll = switchable.isNotEmpty(),
|
||||
allEnabled = switchable.all { it.isVisibleInSystem },
|
||||
onToggleAll = { enabled ->
|
||||
onSetAccountVisible(switchable.map { it.id }, enabled)
|
||||
},
|
||||
) {
|
||||
cals.forEachIndexed { index, calendar ->
|
||||
val disabled = calendar.id in disabledIds
|
||||
// Actionable calendars first; non-syncing ones at the
|
||||
// bottom, dimmed and switchless.
|
||||
val ordered = cals.orderedForManager()
|
||||
ordered.forEachIndexed { index, calendar ->
|
||||
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = if (index == cals.lastIndex) Position.Bottom else Position.Middle,
|
||||
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 = {
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
enabled = !disabled,
|
||||
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||
)
|
||||
trailing = if (calendar.hasVisibilitySwitch) {
|
||||
{
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
enabled = calendar.isVisibleInSystem,
|
||||
onToggle = { enabled ->
|
||||
onSetVisible(calendar.id, enabled)
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -401,13 +340,6 @@ private fun CalendarsList(
|
||||
}
|
||||
}
|
||||
|
||||
if (showInterval) {
|
||||
BackupIntervalDialog(
|
||||
currentMinutes = autoBackup.intervalMinutes,
|
||||
onConfirm = onSetAutoBackupInterval,
|
||||
onDismiss = { showInterval = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -421,12 +353,14 @@ private fun CalendarEditor(
|
||||
onSave: (name: String, color: Int, description: String?) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
deleteLocked: Boolean = false,
|
||||
) {
|
||||
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
|
||||
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
||||
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
@@ -453,11 +387,20 @@ private fun CalendarEditor(
|
||||
},
|
||||
actions = {
|
||||
if (!isNew) {
|
||||
IconButton(onClick = { confirmDelete = true }) {
|
||||
// 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 = MaterialTheme.colorScheme.error,
|
||||
tint = if (deleteLocked) {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -486,7 +429,20 @@ private fun CalendarEditor(
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = pastelize(color, dark)) {
|
||||
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 = eventFill(color, dark, soften)) {
|
||||
InlineTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
@@ -558,10 +514,28 @@ private fun CalendarEditor(
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-row enable/disable control. Checked = the calendar is shown in the
|
||||
* app; unchecking disables it (events, filters and pickers all drop it) without
|
||||
* touching any provider data. Carries its own content description so the toggle
|
||||
* is self-describing to screen readers even on a dimmed row.
|
||||
* The row's supporting line: the states that make this calendar behave unlike a
|
||||
* plain writable one (#76), then its own description.
|
||||
*/
|
||||
@Composable
|
||||
private fun calendarRowSummary(calendar: CalendarSource): String? {
|
||||
val states = calendar.stateLabels().map { label ->
|
||||
stringResource(
|
||||
when (label) {
|
||||
CalendarStateLabel.MANAGED -> R.string.calendars_state_managed
|
||||
CalendarStateLabel.READ_ONLY -> R.string.calendars_state_read_only
|
||||
CalendarStateLabel.NOT_SYNCED -> R.string.calendars_state_not_synced
|
||||
},
|
||||
)
|
||||
}
|
||||
val parts = states + listOfNotNull(calendar.description?.takeIf { it.isNotBlank() })
|
||||
return parts.joinToString(" · ").ifEmpty { null }
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-row on/off control, writing the system's device-local
|
||||
* `Calendars.VISIBLE`. Unchecked drops the calendar out of every surface and
|
||||
* stops its reminders. Carries its own content description.
|
||||
*/
|
||||
@Composable
|
||||
private fun EnableSwitch(
|
||||
@@ -569,7 +543,7 @@ private fun EnableSwitch(
|
||||
enabled: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
val label = stringResource(R.string.calendars_show_in_app_a11y, calendarName)
|
||||
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
@@ -664,8 +638,8 @@ private fun CalendarGroup(
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
Column(content = body)
|
||||
}
|
||||
@@ -741,175 +715,33 @@ private fun CalendarGroupMenu(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a
|
||||
* round 40dp chip, so each synced account is recognisable at a glance. We load
|
||||
* whatever app owns the account from [PackageManager] rather than bundling brand
|
||||
* logos — always accurate, nothing to license. Falls back to a neutral cloud
|
||||
* chip when no installed app resolves for the account.
|
||||
*/
|
||||
@Composable
|
||||
private fun SourceLogo(accountType: String) {
|
||||
val context = LocalContext.current
|
||||
val logo = remember(accountType) { sourceAppLogo(context, accountType) }
|
||||
if (logo != null) {
|
||||
Image(
|
||||
bitmap = logo,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
} else {
|
||||
LeadingAvatar(Icons.Default.Cloud)
|
||||
}
|
||||
}
|
||||
|
||||
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
|
||||
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
|
||||
val pm = context.packageManager
|
||||
val candidates = buildList {
|
||||
curatedSourcePackage(accountType)?.let { add(it) }
|
||||
AccountManager.get(context).authenticatorTypes
|
||||
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
||||
?.packageName
|
||||
?.let { add(it) }
|
||||
}
|
||||
for (pkg in candidates) {
|
||||
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
|
||||
if (bitmap != null) return bitmap.asImageBitmap()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */
|
||||
@Composable
|
||||
private fun LeadingAvatar(icon: ImageVector) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
internal fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||
// 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
|
||||
private fun HintText(text: String) {
|
||||
internal fun HintText(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
||||
@Composable
|
||||
private fun rememberFolderName(uriString: String?): String? {
|
||||
val context = LocalContext.current
|
||||
return remember(uriString) {
|
||||
uriString?.let {
|
||||
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
|
||||
@Composable
|
||||
private fun backupIntervalLabel(minutes: Long): String {
|
||||
val duration = when {
|
||||
minutes % MINUTES_PER_WEEK == 0L ->
|
||||
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
|
||||
minutes % MINUTES_PER_DAY == 0L ->
|
||||
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
|
||||
minutes % 60L == 0L ->
|
||||
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
|
||||
else ->
|
||||
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
|
||||
}
|
||||
return stringResource(R.string.calendars_auto_backup_every, duration)
|
||||
}
|
||||
|
||||
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
|
||||
@Composable
|
||||
private fun backupStatusText(status: BackupStatus): String {
|
||||
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
||||
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 {
|
||||
stringResource(R.string.calendars_auto_backup_status_failed, relative)
|
||||
}
|
||||
}
|
||||
|
||||
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
|
||||
@Composable
|
||||
private fun BackupIntervalDialog(
|
||||
currentMinutes: Long,
|
||||
onConfirm: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// minutes-per-unit for each entry; pick the largest unit the current value divides into.
|
||||
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
|
||||
val units = stringArrayResource(R.array.backup_interval_units).toList()
|
||||
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
|
||||
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
|
||||
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
|
||||
text = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
|
||||
Spacer(Modifier.width(12.dp))
|
||||
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_auto_backup_interval_min),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
|
||||
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
|
||||
onDismiss()
|
||||
}) { Text(stringResource(R.string.reminder_custom_set)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private const val MINUTES_PER_DAY = 1_440L
|
||||
private const val MINUTES_PER_WEEK = 10_080L
|
||||
|
||||
/**
|
||||
* Pick the app to open for managing a synced calendar's account. The account's
|
||||
@@ -935,9 +767,3 @@ private fun sourceAppIntent(context: Context, accountType: String): Intent {
|
||||
return Intent(Settings.ACTION_SYNC_SETTINGS)
|
||||
}
|
||||
|
||||
/** Preferred app for account types whose authenticator isn't the app to open. */
|
||||
private fun curatedSourcePackage(accountType: String): String? = when {
|
||||
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
||||
else -> null
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,7 @@ 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.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier
|
||||
import de.jeanlucmakiola.calendula.data.reminders.SuppressedReminderStore
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -45,10 +42,7 @@ class CalendarsViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val repository: CalendarRepository,
|
||||
private val icsExporter: IcsExporter,
|
||||
private val prefs: CalendarPrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val suppressedStore: SuppressedReminderStore,
|
||||
private val notifier: ReminderNotifier,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -62,20 +56,6 @@ class CalendarsViewModel @Inject constructor(
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Calendars the user has disabled in the app. This screen is the only
|
||||
* surface that lists them, so it both reads the set (to dim the rows) and
|
||||
* toggles it. Every other surface simply excludes these ids.
|
||||
*/
|
||||
val disabledCalendarIds: StateFlow<Set<Long>> =
|
||||
prefs.disabledCalendarIds
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||
settingsPrefs.autoBackupEnabled,
|
||||
@@ -92,6 +72,25 @@ class CalendarsViewModel @Inject constructor(
|
||||
initialValue = AutoBackupUiState(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Managed special-dates calendars whose deletion would not stick: while the
|
||||
* feature is on, `SpecialDatesSyncEngine.reconcileCalendars` undoes it on
|
||||
* the next pass. Read off each calendar's durable [CalendarSource.isManaged]
|
||||
* marker rather than the stored ids, which lag a sync pass behind.
|
||||
*/
|
||||
val deleteLockedCalendarIds: StateFlow<Set<Long>> = combine(
|
||||
calendars,
|
||||
settingsPrefs.specialDatesEnabled,
|
||||
) { sources, enabled ->
|
||||
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
private val _error = MutableStateFlow(false)
|
||||
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||
|
||||
@@ -107,11 +106,11 @@ class CalendarsViewModel @Inject constructor(
|
||||
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
|
||||
* in [backupResult] for a one-shot message.
|
||||
*/
|
||||
fun exportBackup(uri: Uri) {
|
||||
fun exportBackup(uri: Uri, calendarIds: Set<Long>? = null) {
|
||||
viewModelScope.launch {
|
||||
_backupResult.value = try {
|
||||
val count = withContext(io) {
|
||||
val events = repository.exportEvents()
|
||||
val events = repository.exportEvents(calendarIds)
|
||||
icsExporter.writeDocument(
|
||||
uri = uri,
|
||||
content = IcsWriter().writeCalendar(events, Clock.System.now()),
|
||||
@@ -140,54 +139,21 @@ class CalendarsViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable a calendar app-side. Disabling removes it from every
|
||||
* surface but Settings → Calendars (and hides its events) without touching
|
||||
* provider data — purely a reversible Calendula-local view choice.
|
||||
* Switch a calendar on or off — the app's one visibility model, writing the
|
||||
* system's `Calendars.VISIBLE`. A reminder that came due while the calendar
|
||||
* was off stays gone when it is switched back on: the watermark has already
|
||||
* moved past it (#75).
|
||||
*/
|
||||
fun setDisabled(id: Long, disabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val current = prefs.disabledCalendarIds.first()
|
||||
val next = if (disabled) current + id else current - id
|
||||
if (next != current) {
|
||||
prefs.setDisabledCalendarIds(next)
|
||||
if (!disabled) recoverReminders(setOf(id))
|
||||
}
|
||||
}
|
||||
fun setCalendarVisible(id: Long, visible: Boolean) = write {
|
||||
repository.setCalendarsVisible(listOf(id), visible)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable every calendar of one account in a single write — the
|
||||
* "toggle all" affordance on an account header. Done as one set update so the
|
||||
* per-calendar [setDisabled] calls can't race each other.
|
||||
* Switch every calendar of one account on or off. Each row is written on its
|
||||
* own, in one coroutine so the writes can't race.
|
||||
*/
|
||||
fun setAccountDisabled(ids: Collection<Long>, disabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val current = prefs.disabledCalendarIds.first()
|
||||
val next = if (disabled) current + ids else current - ids.toSet()
|
||||
if (next != current) {
|
||||
prefs.setDisabledCalendarIds(next)
|
||||
if (!disabled) recoverReminders(current intersect ids.toSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-post the reminders that fired while [reEnabledIds] were disabled and are
|
||||
* still relevant (event not yet over), then drop them from the stash. Runs
|
||||
* after the disabled set is written, so the notifier's own disabled gate lets
|
||||
* them through. Best-effort at re-enable time: it mirrors the receiver gates
|
||||
* (reminders on + postable), and there is no later re-scan, so alerts left
|
||||
* unposted because those gates are closed are simply released.
|
||||
*/
|
||||
private suspend fun recoverReminders(reEnabledIds: Set<Long>) {
|
||||
if (reEnabledIds.isEmpty()) return
|
||||
val recovered = suppressedStore.recoverFor(reEnabledIds, System.currentTimeMillis())
|
||||
if (recovered.isNotEmpty() &&
|
||||
settingsPrefs.remindersEnabled.first() &&
|
||||
notifier.canPost()
|
||||
) {
|
||||
recovered.forEach { notifier.post(it) }
|
||||
}
|
||||
fun setAccountVisible(ids: Collection<Long>, visible: Boolean) = write {
|
||||
repository.setCalendarsVisible(ids, visible)
|
||||
}
|
||||
|
||||
// --- Automatic backup (issue #8) ------------------------------------
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
|
||||
/**
|
||||
* One account's calendars, as every surface that lists calendars by account
|
||||
* shows them. An account is identified by name **and** type (#77): a Google and
|
||||
* a DAVx5 account can share an address and still be two separate accounts.
|
||||
*/
|
||||
data class CalendarAccountGroup(
|
||||
/** Stable identity: what makes two calendars belong to the same account. */
|
||||
val key: AccountKey,
|
||||
/** The account's own name, as shown when it is unambiguous. */
|
||||
val label: String,
|
||||
/** True when another group shows the same [label] under a different type. */
|
||||
val ambiguous: Boolean,
|
||||
val calendars: List<CalendarSource>,
|
||||
) {
|
||||
val accountType: String get() = key.type
|
||||
}
|
||||
|
||||
/** The pair a group is keyed on. */
|
||||
data class AccountKey(val name: String, val type: String)
|
||||
|
||||
/**
|
||||
* Group [calendars] under their owning account, preserving the provider's order
|
||||
* within each group and ordering groups by first appearance.
|
||||
*
|
||||
* The label falls back through name → type → the first calendar's own name, so
|
||||
* a calendar with no account still lands somewhere sensible.
|
||||
*/
|
||||
fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
|
||||
val grouped = groupBy { AccountKey(it.accountName, it.accountType) }
|
||||
val labels = grouped.mapValues { (key, cals) ->
|
||||
key.name.ifBlank { key.type }.ifBlank { cals.first().displayName }
|
||||
}
|
||||
val shared = labels.values.groupingBy { it }.eachCount()
|
||||
return grouped.map { (key, cals) ->
|
||||
val label = labels.getValue(key)
|
||||
CalendarAccountGroup(
|
||||
key = key,
|
||||
label = label,
|
||||
ambiguous = shared.getValue(label) > 1,
|
||||
calendars = cals,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What to write above a group: its account name, qualified with the app the
|
||||
* account comes from when another account shares the name (#77).
|
||||
*/
|
||||
@Composable
|
||||
fun accountGroupTitle(group: CalendarAccountGroup): String =
|
||||
if (!group.ambiguous) {
|
||||
group.label
|
||||
} else {
|
||||
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
|
||||
* account type when no installed app resolves for it.
|
||||
*/
|
||||
@Composable
|
||||
fun sourceAppName(accountType: String): String {
|
||||
val context = LocalContext.current
|
||||
return remember(accountType) {
|
||||
val pm = context.packageManager
|
||||
val packages = sourceAppPackages(context, accountType)
|
||||
packages.firstNotNullOfOrNull { pkg ->
|
||||
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
|
||||
} ?: accountType
|
||||
}
|
||||
}
|
||||
@@ -13,31 +13,18 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Soften a raw calendar color toward a pastel that fits the active theme.
|
||||
* - Keeps the hue (so users still recognise their calendars)
|
||||
* - Caps saturation so harsh provider colors stop screaming
|
||||
* - Pins value/brightness to a band that reads on both light and dark surfaces
|
||||
*/
|
||||
fun pastelize(rawArgb: Int, dark: Boolean): Color {
|
||||
val hsv = FloatArray(3)
|
||||
android.graphics.Color.colorToHSV(rawArgb, hsv)
|
||||
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
|
||||
hsv[2] = if (dark) 0.82f else 0.72f
|
||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||
}
|
||||
|
||||
/**
|
||||
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
|
||||
* in the calendar's (pastelised) colour. Shared by the calendar manager and the
|
||||
* visibility filter so they read identically.
|
||||
* in the calendar's colour — softened to a pastel, or raw when the softener is
|
||||
* off (issue #36). Shared by the calendar manager and the visibility filter so
|
||||
* they read identically.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(40.dp)
|
||||
@@ -48,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
||||
Icon(
|
||||
Icons.Filled.CalendarMonth,
|
||||
contentDescription = null,
|
||||
tint = pastelize(color, dark),
|
||||
tint = eventFill(color, dark, soften),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
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
|
||||
* and the .ics import screen. Renders the same grouped-card system as the
|
||||
* calendar-manager screen: a category header per source — the device chip for
|
||||
* the app's own calendars, the owning app's launcher icon for each synced
|
||||
* account — with the calendars beneath it as a connected card, a colour chip on
|
||||
* each and a check on the selected one. Emits into the caller's [ColumnScope]
|
||||
* (a scrolling column), so the caller owns the surrounding chrome.
|
||||
*
|
||||
* The list holds event *targets* only, so a switched-off, read-only or managed
|
||||
* calendar is absent (#76). [onManageCalendars], when given, adds the footer row
|
||||
* naming the possible reasons and opening the calendar manager.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.CalendarPickerGroups(
|
||||
calendars: List<CalendarSource>,
|
||||
selectedId: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
) {
|
||||
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
||||
val syncedGroups = remember(calendars) {
|
||||
calendars.filterNot { it.isLocal }.groupByAccount()
|
||||
}
|
||||
|
||||
if (local.isNotEmpty()) {
|
||||
CalendarPickerGroup(
|
||||
title = stringResource(R.string.calendars_local_header),
|
||||
leading = { LeadingAvatar(Icons.Default.PhoneAndroid) },
|
||||
calendars = local,
|
||||
selectedId = selectedId,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
syncedGroups.forEachIndexed { index, group ->
|
||||
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
||||
CalendarPickerGroup(
|
||||
title = accountGroupTitle(group),
|
||||
leading = { SourceLogo(group.accountType) },
|
||||
calendars = group.calendars,
|
||||
selectedId = selectedId,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
if (onManageCalendars != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendar_picker_missing_title),
|
||||
summary = stringResource(R.string.calendar_picker_missing_summary),
|
||||
position = Position.Alone,
|
||||
leading = { LeadingAvatar(Icons.Default.VisibilityOff) },
|
||||
trailing = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = onManageCalendars,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One account's category header (avatar + name) atop its selectable calendars. */
|
||||
@Composable
|
||||
private fun CalendarPickerGroup(
|
||||
title: String,
|
||||
leading: @Composable () -> Unit,
|
||||
calendars: List<CalendarSource>,
|
||||
selectedId: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = title,
|
||||
position = Position.Top,
|
||||
leading = leading,
|
||||
)
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id == selectedId
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
||||
selected = isSelected,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onSelect(calendar.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a
|
||||
* round 40dp chip, so each synced account is recognisable at a glance. We load
|
||||
* whatever app owns the account from [android.content.pm.PackageManager] rather
|
||||
* than bundling brand logos — always accurate, nothing to license. Falls back to
|
||||
* a neutral cloud chip when no installed app resolves for the account.
|
||||
*/
|
||||
@Composable
|
||||
fun SourceLogo(accountType: String) {
|
||||
val context = LocalContext.current
|
||||
val logo = remember(accountType) { sourceAppLogo(context, accountType) }
|
||||
if (logo != null) {
|
||||
Image(
|
||||
bitmap = logo,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
} else {
|
||||
LeadingAvatar(Icons.Default.Cloud)
|
||||
}
|
||||
}
|
||||
|
||||
/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */
|
||||
@Composable
|
||||
fun LeadingAvatar(icon: ImageVector) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
|
||||
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
|
||||
val pm = context.packageManager
|
||||
for (pkg in sourceAppPackages(context, accountType)) {
|
||||
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
|
||||
if (bitmap != null) return bitmap.asImageBitmap()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Apps that could stand for [accountType], best candidate first. */
|
||||
internal fun sourceAppPackages(context: Context, accountType: String): List<String> = buildList {
|
||||
curatedSourcePackage(accountType)?.let { add(it) }
|
||||
AccountManager.get(context).authenticatorTypes
|
||||
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
||||
?.packageName
|
||||
?.let { add(it) }
|
||||
}
|
||||
|
||||
/** Preferred app for account types whose authenticator isn't the app to open. */
|
||||
internal fun curatedSourcePackage(accountType: String): String? = when {
|
||||
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
||||
else -> null
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Formats a calendar title (top bar or widget header): [skeleton]'s fields laid
|
||||
* out in [locale]'s own order, with the year shown only when [date] falls outside
|
||||
* [currentYear].
|
||||
*
|
||||
* Pass [skeleton] *without* a year field — "LLLL" for a month, "EEEdMMM" for a
|
||||
* day. A skeleton is a field list, so wanting the year is just asking for one
|
||||
* more field; the locale still decides where it lands ("July 2026" vs "2026年7月").
|
||||
*
|
||||
* Dropping the year in the current year is the one bit of policy here: the title
|
||||
* sits directly above a grid that already says which year it is, and the year's
|
||||
* *absence* is itself the signal that you're in the current one — it appears the
|
||||
* moment you page out of it, which is when it starts carrying information.
|
||||
*
|
||||
* [forceYear] overrides that for titles whose [date] does not tell the whole
|
||||
* story — a week view's title names only the month its *first* day falls in, so
|
||||
* a week straddling New Year must still show the year even though [date] is in
|
||||
* [currentYear].
|
||||
*/
|
||||
fun formatCalendarTitle(
|
||||
date: java.time.LocalDate,
|
||||
locale: Locale,
|
||||
currentYear: Int,
|
||||
skeleton: String,
|
||||
forceYear: Boolean = false,
|
||||
): String {
|
||||
val fields = if (date.year == currentYear && !forceYear) skeleton else skeleton + "y"
|
||||
return localizedDateFormatter(locale, fields).format(date)
|
||||
}
|
||||
@@ -1,61 +1,36 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.provider.Settings
|
||||
import androidx.activity.BackEventCompat
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Whether the user has asked the system to remove animations (Settings →
|
||||
* Accessibility → "Remove animations", which sets the global animator duration
|
||||
* scale to 0). Compose animations do *not* honour this platform flag on their
|
||||
* own, so the shared motion helpers in this file check it and fall back to a
|
||||
* quick cross-fade — opacity only, no spatial movement — to respect the
|
||||
* vestibular intent of the setting while keeping state changes legible.
|
||||
*
|
||||
* Read once at composition; the scale changes rarely and only takes full effect
|
||||
* after a process restart anyway.
|
||||
* Calendar-specific motion. The family's generic content transitions — section
|
||||
* expand/collapse, item reveal, list relayout, cross-fade, predictive-back, and
|
||||
* 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 directional
|
||||
* month/week/day slide and the specs that feed it.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberReduceMotion(): Boolean {
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
return remember(resolver) {
|
||||
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
||||
* spring-physics spec from the active motion scheme — snappy with a subtle
|
||||
* springy settle, rather than a fixed easing curve.
|
||||
* 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.
|
||||
*
|
||||
* Default rather than fast: `fastSpatialSpec` is tuned for small, incidental
|
||||
* movement, and driving a whole calendar page with it made the settle read as a
|
||||
* twitch instead of a glide.
|
||||
*
|
||||
* Read it in a composable scope (this helper) so it can be captured by the
|
||||
* non-composable `AnimatedContent` transitionSpec lambda.
|
||||
@@ -63,27 +38,34 @@ fun rememberReduceMotion(): Boolean {
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||
|
||||
/**
|
||||
* The fast effects spec from the active motion scheme, for opacity (fade)
|
||||
* transitions. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
||||
* for use in non-composable transition lambdas and as the reduced-motion fallback.
|
||||
* The effects spec from the active motion scheme, for the opacity half of the
|
||||
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
||||
* for use in non-composable transition lambdas, and reused on its own as the
|
||||
* reduced-motion fallback.
|
||||
*
|
||||
* Opacity gets its own spec on purpose: M3 Expressive springs *position* and
|
||||
* eases *opacity*, and a fade that bounced with the slide would shimmer.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||
MaterialTheme.motionScheme.defaultEffectsSpec()
|
||||
|
||||
/**
|
||||
* Horizontal slide for navigating between adjacent months/weeks/days.
|
||||
* 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 reduced-motion fade, typically
|
||||
* @param fadeSpec effects spec for the opacity half, and for the whole
|
||||
* transition under reduced motion; typically
|
||||
* [rememberCalendarFadeSpec].
|
||||
* @param reduceMotion when true, swap the directional slide for a plain cross-fade.
|
||||
* @param reduceMotion when true, drop the movement and cross-fade alone.
|
||||
*/
|
||||
fun calendarSlideTransition(
|
||||
slideDir: Int,
|
||||
@@ -95,131 +77,26 @@ fun calendarSlideTransition(
|
||||
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||
}
|
||||
val dir = if (slideDir == 0) 1 else slideDir
|
||||
return slideInHorizontally(spec) { w -> dir * w }
|
||||
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-fade [ContentTransform] for swapping whole screens or content blocks
|
||||
* where there is no meaningful spatial direction (e.g. onboarding gates). Pure
|
||||
* opacity, so it doubles as its own reduced-motion form.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun calendarFadeThrough(): ContentTransform {
|
||||
val fade = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||
return fadeIn(fade).togetherWith(fadeOut(fade))
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter transition for a vertically-revealed section (expandable rows, inline
|
||||
* fields): height grows from the top while fading in. Under reduced motion the
|
||||
* height growth is dropped, leaving a quick fade.
|
||||
* How far a page travels, as a fraction of the container width.
|
||||
*
|
||||
* Pair with [calendarCollapseExit]. This is the promoted form of the pattern
|
||||
* originally inlined in the event edit form, so every expandable surface in the
|
||||
* app reveals the same way.
|
||||
* 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.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun calendarExpandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
||||
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
||||
return if (reduceMotion) {
|
||||
fade
|
||||
} else {
|
||||
expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
||||
}
|
||||
}
|
||||
|
||||
/** Exit counterpart to [calendarExpandEnter]: shrink + fade, or fade only under reduced motion. */
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun calendarCollapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition {
|
||||
val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec())
|
||||
return if (reduceMotion) {
|
||||
fade
|
||||
} else {
|
||||
shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter transition for content revealed by an `AnimatedContent`/`AnimatedVisibility`
|
||||
* (e.g. search results once a query resolves): a gentle rise + fade. Reduced
|
||||
* motion keeps the fade only.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun calendarItemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
||||
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
||||
return if (reduceMotion) {
|
||||
fade
|
||||
} else {
|
||||
fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared [LazyItemScope.animateItem] wiring so list rows fade/relocate with the
|
||||
* app's motion scheme instead of Compose's default spring. Returns a bare
|
||||
* [Modifier] under reduced motion so rows snap into place. Requires the list to
|
||||
* supply stable item keys.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun LazyItemScope.calendarAnimateItem(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
|
||||
if (reduceMotion) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.animateItem(
|
||||
fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||
placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
||||
fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard Android predictive-back transform for a full-screen overlay: as the
|
||||
* back gesture is dragged, the surface scales toward ~90%, shifts toward the
|
||||
* swiped edge and rounds its corners, previewing what's behind. Completing the
|
||||
* gesture invokes [onBack]; cancelling springs it back.
|
||||
*
|
||||
* A drop-in replacement for a screen's own `BackHandler(onBack)` — register it
|
||||
* once and apply the returned [Modifier] to that screen's root so the preview
|
||||
* respects the same back semantics. Under reduced motion the visual preview is
|
||||
* skipped (the back still works); on API < 34 the system delivers no progress,
|
||||
* so it degrades to a plain back.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.predictiveBack(
|
||||
onBack: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
reduceMotion: Boolean = rememberReduceMotion(),
|
||||
): Modifier {
|
||||
val progress = remember { Animatable(0f) }
|
||||
var fromLeftEdge by remember { mutableStateOf(true) }
|
||||
|
||||
PredictiveBackHandler(enabled = enabled) { events ->
|
||||
try {
|
||||
events.collect { event ->
|
||||
fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT
|
||||
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
|
||||
}
|
||||
onBack()
|
||||
progress.snapTo(0f)
|
||||
} catch (_: CancellationException) {
|
||||
progress.animateTo(0f)
|
||||
}
|
||||
}
|
||||
|
||||
if (reduceMotion) return this
|
||||
return this.graphicsLayer {
|
||||
val p = progress.value
|
||||
val scale = 1f - 0.1f * p
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p
|
||||
shape = RoundedCornerShape(32.dp.toPx() * p)
|
||||
clip = p > 0f
|
||||
}
|
||||
}
|
||||
private const val SLIDE_TRAVEL_DIVISOR = 5
|
||||
|
||||
@@ -18,14 +18,14 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A wrapping row of round colour swatches; the one matching [selected] is
|
||||
* ringed and checked. Shared by the calendar editor and the event-colour
|
||||
* picker so both pick a colour the same way. Swatches render through
|
||||
* [pastelize] — the softened colour the app actually paints, not the raw hue.
|
||||
* [eventFill] — the colour the app actually paints (softened, or raw when the
|
||||
* softener is off, issue #36), not necessarily the stored hue.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@@ -36,16 +36,18 @@ fun ColorSwatchRow(
|
||||
dark: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val soften = LocalSoftenColors.current
|
||||
FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
colors.forEach { argb ->
|
||||
val isSelected = argb == selected
|
||||
val fill = eventFill(argb, dark, soften)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 4.dp)
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(pastelize(argb, dark))
|
||||
.background(fill)
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
||||
@@ -59,7 +61,7 @@ fun ColorSwatchRow(
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = Color.Black.copy(alpha = 0.7f),
|
||||
tint = eventInk(fill, alpha = 0.7f),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
|
||||
/**
|
||||
* A Flutter-style "DEBUG" corner ribbon, drawn across the top-right corner of
|
||||
* the app. Deliberately a stark, un-themed marker (not a product component) so a
|
||||
* debug build is unmistakable at a glance — gate it on `BuildConfig.DEBUG` at the
|
||||
* call site so it never reaches a release build. Non-interactive: it's a plain
|
||||
* label with no pointer handler, so taps fall through to whatever is beneath it.
|
||||
*
|
||||
* Drop it in as the last child of a full-screen [androidx.compose.foundation.layout.Box]
|
||||
* so it overlays the UI.
|
||||
*/
|
||||
@Composable
|
||||
fun BoxScope.DebugRibbon() {
|
||||
Text(
|
||||
text = "DEBUG",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.zIndex(1f)
|
||||
// Push the band out so its midline crosses the very corner, then
|
||||
// rotate it to the classic 45° ribbon.
|
||||
.offset(x = 36.dp, y = 24.dp)
|
||||
.rotate(45f)
|
||||
.background(Color(0xFFB23B00))
|
||||
.width(140.dp)
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Tonal 3-digit number input shared by the custom reminder/recurrence steps and
|
||||
* the reminder pickers — the app's [InlineTextField] over a tonal surface, so it
|
||||
* matches the card/grouped-row design language (not Material's outlined field).
|
||||
*/
|
||||
@Composable
|
||||
fun DialogAmountField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
) {
|
||||
// surfaceContainerHighest — the picker/dialog sits on surfaceContainerHigh,
|
||||
// so anything lower vanishes.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
InlineTextField(
|
||||
value = value,
|
||||
onValueChange = { text ->
|
||||
if (text.length <= 3 && text.all(Char::isDigit)) onValueChange(text)
|
||||
},
|
||||
placeholder = placeholder,
|
||||
textStyle = MaterialTheme.typography.titleMedium,
|
||||
keyboardType = KeyboardType.Number,
|
||||
modifier = Modifier
|
||||
.width(72.dp)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps and pickers. */
|
||||
@Composable
|
||||
fun DialogUnitDropdown(
|
||||
label: String,
|
||||
entries: List<String>,
|
||||
onPick: (Int) -> Unit,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
onClick = { open = true },
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
|
||||
) {
|
||||
Text(text = label, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null)
|
||||
}
|
||||
}
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
entries.forEachIndexed { index, entry ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(entry) },
|
||||
onClick = {
|
||||
onPick(index)
|
||||
open = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
|
||||
/**
|
||||
* Whether calendar/event colours are softened toward theme-fitting pastels
|
||||
* before display (issue #36). Provided app-wide from the "soften colours"
|
||||
* setting; the default `true` keeps the historical look. When off, the raw
|
||||
* provider colour is painted verbatim — matching the sync source (DAVx5/CalDAV)
|
||||
* and other calendar apps. Widgets live outside this composition and read the
|
||||
* preference directly, then pass the flag to [eventFill] / [eventInk].
|
||||
*/
|
||||
val LocalSoftenColors = staticCompositionLocalOf { true }
|
||||
|
||||
/**
|
||||
* Display fill for an event chip/bar or a calendar tint: the [pastelize]d colour
|
||||
* when [soften] is on, else the raw provider ARGB verbatim (forced opaque, since
|
||||
* pastelize also returns an opaque colour).
|
||||
*/
|
||||
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
||||
if (soften) pastelize(rawArgb, dark) else Color(rawArgb or 0xFF000000.toInt())
|
||||
|
||||
/**
|
||||
* Contrast ink (title text / glyph) for a filled chip painted with [eventFill]:
|
||||
* white on a dark fill, near-black on a light one (issue #21). Applies to both
|
||||
* softened and raw fills — a saturated hue (deep blue, purple, red) is
|
||||
* perceptually dark even after softening pins its HSV value, so black text on it
|
||||
* reads poorly. The choice is objective, not a tuned threshold: white wins only
|
||||
* when it out-contrasts black against the fill, which (by the WCAG contrast
|
||||
* ratio) is at relative luminance ≈ 0.18 — so mid and light colours keep
|
||||
* near-black text. [alpha] carries the caller's soft emphasis.
|
||||
*/
|
||||
fun eventInk(fill: Color, alpha: Float = 0.8f): Color {
|
||||
val useWhite = fill.luminance() < INK_LUMINANCE_CROSSOVER
|
||||
return (if (useWhite) Color.White else Color.Black).copy(alpha = alpha)
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative-luminance crossover where white text starts to out-contrast black.
|
||||
* Solving `contrast(white, L) = contrast(black, L)` on the WCAG ratio gives
|
||||
* `L = sqrt(1.05 * 0.05) - 0.05 ≈ 0.179`.
|
||||
*/
|
||||
private const val INK_LUMINANCE_CROSSOVER = 0.179f
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
@@ -24,6 +25,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
||||
EventFormField.Location -> R.string.event_detail_location
|
||||
EventFormField.Description -> R.string.event_detail_description
|
||||
EventFormField.Timezone -> R.string.event_detail_timezone
|
||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||
EventFormField.Availability -> R.string.event_edit_availability
|
||||
@@ -35,6 +37,7 @@ fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
||||
fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||
EventFormField.Location -> Icons.Default.Place
|
||||
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
||||
EventFormField.Timezone -> Icons.Default.Public
|
||||
EventFormField.Reminders -> Icons.Default.Notifications
|
||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LargeTopAppBar
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
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.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* Position of a row within a grouped list, after the Android-15 settings
|
||||
* pattern: a run of rows shares one rounded container, with full corners at the
|
||||
* group's outer edges and small corners between, separated by small gaps.
|
||||
*/
|
||||
enum class Position { Top, Middle, Bottom, Alone }
|
||||
|
||||
/** Maps an index within a group of [count] rows to its [Position]. */
|
||||
fun positionOf(index: Int, count: Int): Position = when {
|
||||
count <= 1 -> Position.Alone
|
||||
index == 0 -> Position.Top
|
||||
index == count - 1 -> Position.Bottom
|
||||
else -> Position.Middle
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's standard full-screen list scaffold. By default a collapsing
|
||||
* [LargeTopAppBar] whose title shrinks into the bar (next to the back button) as
|
||||
* the content scrolls — used by Settings and the calendar manager, where the
|
||||
* large header sets the page. Content is a scrollable column that feeds the
|
||||
* toolbar via nested scroll.
|
||||
*
|
||||
* Set [largeTopBar] to false for a pinned, single-line [TopAppBar] instead: the
|
||||
* title sits in the bar from the start, with no expanded header to scroll past.
|
||||
* Preferred for selection pickers, where the tall header is just wasted space
|
||||
* above the options.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CollapsingScaffold(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
largeTopBar: Boolean = true,
|
||||
snackbarHost: @Composable () -> Unit = {},
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val scrollBehavior = if (largeTopBar) {
|
||||
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
} else {
|
||||
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
}
|
||||
Scaffold(
|
||||
modifier = modifier
|
||||
.predictiveBack(onBack = onBack)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
val navigationIcon = @Composable {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.settings_back),
|
||||
)
|
||||
}
|
||||
}
|
||||
val colors = TopAppBarDefaults.topAppBarColors(
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
)
|
||||
if (largeTopBar) {
|
||||
LargeTopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = navigationIcon,
|
||||
actions = actions,
|
||||
scrollBehavior = scrollBehavior,
|
||||
colors = colors,
|
||||
)
|
||||
} else {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = navigationIcon,
|
||||
actions = actions,
|
||||
scrollBehavior = scrollBehavior,
|
||||
colors = colors,
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = snackbarHost,
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
// Mark the scaffold's system-bar insets as consumed so the
|
||||
// imePadding below adds only the keyboard height beyond them
|
||||
// (max, not sum) — otherwise the nav-bar inset double-counts and
|
||||
// leaves an empty strip above the keyboard.
|
||||
.consumeWindowInsets(innerPadding)
|
||||
.fillMaxSize()
|
||||
// Paint the surface across the full area before imePadding carves
|
||||
// into it, so any sliver above the keyboard reads as surface — not
|
||||
// the dialog window's black — during the IME animation.
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
// Shrink the scroll viewport by the keyboard inset so a focused
|
||||
// field (e.g. the custom-reminder amount) can scroll into view.
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(top = 8.dp, bottom = 24.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose
|
||||
* corner radii come from its [position] (so a run of rows reads as a single
|
||||
* rounded card). Corners round further on press. A null [onClick] makes the
|
||||
* row non-interactive (e.g. read-only entries). [dimmed] fades the headline and
|
||||
* summary to the M3 disabled emphasis while leaving the [trailing] control at
|
||||
* full opacity — for rows that are present but switched off.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GroupedRow(
|
||||
title: String,
|
||||
position: Position,
|
||||
modifier: Modifier = Modifier,
|
||||
summary: String? = null,
|
||||
selected: Boolean = false,
|
||||
dimmed: Boolean = false,
|
||||
container: Color? = null,
|
||||
minHeight: Dp = 72.dp,
|
||||
// The 2.dp separation between rows in a run. Suppressed by the reorderable
|
||||
// list, which owns uniform spacing itself so every slot has the same pitch.
|
||||
gapBelow: Boolean = true,
|
||||
leading: @Composable (() -> Unit)? = null,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
val pressed by interaction.collectIsPressedAsState()
|
||||
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
|
||||
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
|
||||
val shape = when (position) {
|
||||
Position.Alone -> RoundedCornerShape(full)
|
||||
Position.Top -> RoundedCornerShape(
|
||||
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
|
||||
)
|
||||
Position.Middle -> RoundedCornerShape(small)
|
||||
Position.Bottom -> RoundedCornerShape(
|
||||
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
||||
)
|
||||
}
|
||||
val gap = when {
|
||||
!gapBelow -> Modifier
|
||||
position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||
else -> Modifier
|
||||
}
|
||||
val itemColors = if (selected) {
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
headlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
} else if (dimmed) {
|
||||
// M3 disabled emphasis (0.38α) on the text/leading; the trailing control
|
||||
// stays full-opacity so the toggle reads as live even on a faded row.
|
||||
val muted = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
headlineColor = muted,
|
||||
leadingIconColor = muted,
|
||||
supportingColor = muted,
|
||||
)
|
||||
} else {
|
||||
ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||
}
|
||||
val item: @Composable () -> Unit = {
|
||||
ListItem(
|
||||
headlineContent = { Text(title) },
|
||||
supportingContent = summary?.let { text -> { Text(text) } },
|
||||
leadingContent = leading,
|
||||
trailingContent = trailing,
|
||||
colors = itemColors,
|
||||
modifier = Modifier.heightIn(min = minHeight),
|
||||
)
|
||||
}
|
||||
val base = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.then(gap)
|
||||
val containerColor = when {
|
||||
selected -> MaterialTheme.colorScheme.secondaryContainer
|
||||
container != null -> container
|
||||
else -> MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
if (onClick != null) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = containerColor,
|
||||
shape = shape,
|
||||
interactionSource = interaction,
|
||||
modifier = base,
|
||||
) { item() }
|
||||
} else {
|
||||
Surface(color = containerColor, shape = shape, modifier = base) { item() }
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* The app's borderless text input: no underline, no outline, just the tonal
|
||||
* card behind it. This is the standard input across the app — we deliberately
|
||||
* don't use Material's outlined/filled text fields, so anything that takes text
|
||||
* (the event form, the calendar manager, dialogs) uses this inside a tonal
|
||||
* [androidx.compose.material3.Surface].
|
||||
*/
|
||||
@Composable
|
||||
fun InlineTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
modifier: Modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||
singleLine: Boolean = true,
|
||||
minLines: Int = 1,
|
||||
enabled: Boolean = true,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
||||
imeAction: ImeAction = ImeAction.Default,
|
||||
/** Invoked when the IME action key (e.g. Done) is pressed. */
|
||||
onImeAction: (() -> Unit)? = null,
|
||||
) {
|
||||
val resolvedStyle = textStyle.copy(
|
||||
color = when {
|
||||
// A disabled field reads as dimmed, like a locked value.
|
||||
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
textStyle.color.isSpecified -> textStyle.color
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
enabled = enabled,
|
||||
textStyle = resolvedStyle,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = keyboardType,
|
||||
capitalization = capitalization,
|
||||
imeAction = imeAction,
|
||||
),
|
||||
keyboardActions = onImeAction?.let { action ->
|
||||
KeyboardActions(onAny = { action() })
|
||||
} ?: KeyboardActions.Default,
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
decorationBox = { innerTextField ->
|
||||
Box {
|
||||
if (value.isEmpty()) {
|
||||
// Clearly fainter than typed text, so a hint never reads as
|
||||
// prefilled content.
|
||||
Text(
|
||||
text = placeholder,
|
||||
style = resolvedStyle,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Current display [Locale], read observably from [LocalConfiguration] so the UI
|
||||
* recomposes after a locale change (lint: NonObservableLocale). Used for
|
||||
* weekday/month name formatting.
|
||||
*/
|
||||
@Composable
|
||||
fun currentLocale(): Locale {
|
||||
val configuration = LocalConfiguration.current
|
||||
return remember(configuration) {
|
||||
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* The app's standard pick in a selection dialog: a full-width tonal card,
|
||||
* optionally with a leading icon and a supporting line; the selected option
|
||||
* is highlighted. Stack with 8dp gaps inside an AlertDialog — this is the
|
||||
* only sanctioned selection-modal style (no radio rows, no bare text lists).
|
||||
*/
|
||||
@Composable
|
||||
fun OptionCard(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
icon: ImageVector? = null,
|
||||
/** Icon tint override, e.g. a calendar colour; unspecified follows selection. */
|
||||
iconTint: Color = Color.Unspecified,
|
||||
supportingText: String? = null,
|
||||
selected: Boolean = false,
|
||||
/** Label colour override, e.g. primary for an emphasised "Custom" entry. */
|
||||
labelColor: Color = Color.Unspecified,
|
||||
) {
|
||||
val contentColor = if (selected) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
},
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = when {
|
||||
iconTint.isSpecified -> iconTint
|
||||
selected -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (labelColor.isSpecified) labelColor else contentColor,
|
||||
)
|
||||
if (supportingText != null) {
|
||||
Text(
|
||||
text = supportingText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +1,48 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
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.platform.LocalView
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
|
||||
/**
|
||||
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
||||
* reuses the app's [CollapsingScaffold] (back button + full width), but with a
|
||||
* pinned single-line title rather than the large collapsing header — a picker is
|
||||
* a short list, so the tall header would only be empty space to scroll past.
|
||||
* [content] places the connected grouped rows; selecting one calls [onDismiss].
|
||||
*/
|
||||
@Composable
|
||||
fun FullScreenPicker(
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false,
|
||||
),
|
||||
) {
|
||||
// The dialog window pans by default when the keyboard opens, which —
|
||||
// combined with the content's own imePadding — leaves a fixed black gap
|
||||
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays
|
||||
// full-screen and imePadding alone lifts the focused field.
|
||||
val view = LocalView.current
|
||||
SideEffect {
|
||||
(view.parent as? DialogWindowProvider)?.window
|
||||
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
}
|
||||
CollapsingScaffold(
|
||||
title = title,
|
||||
onBack = onDismiss,
|
||||
largeTopBar = false,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* General single-select picker, full-screen: each option is a connected grouped
|
||||
* row and the current one carries a check. Drop-in for the former dialog
|
||||
* (theme, week start, language, …).
|
||||
*/
|
||||
@Composable
|
||||
fun <T> OptionPicker(
|
||||
title: String,
|
||||
options: List<T>,
|
||||
selected: T,
|
||||
label: @Composable (T) -> String,
|
||||
onSelect: (T) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
header: (@Composable ColumnScope.() -> Unit)? = null,
|
||||
) {
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
header?.invoke(this)
|
||||
options.forEachIndexed { index, option ->
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
title = label(option),
|
||||
position = positionOf(index, options.size),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||
@@ -128,20 +53,25 @@ fun <T> OptionPicker(
|
||||
* and, for a per-calendar picker ([allowInherit]), "Use default reminder", which
|
||||
* defers to the global default. Clearing the last checked time reverts to "Use
|
||||
* default" on a per-calendar picker (so an accidental toggle-undo can't silently
|
||||
* wipe the calendar's default) and to explicit [CalendarReminderOverride.None]
|
||||
* wipe the calendar's default) and to explicit [ReminderOverride.None]
|
||||
* on the global default (where empty legitimately means no reminder). A "Custom"
|
||||
* row expands an inline number field plus a unit selector to add an arbitrary
|
||||
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
|
||||
* back.
|
||||
*
|
||||
* [leadTimeSummary] adds a second line to each lead-time row, for the all-day
|
||||
* pickers: the hour comes from the separate "show all-day reminders at" setting,
|
||||
* so the lead time alone doesn't say when anything happens.
|
||||
*/
|
||||
@Composable
|
||||
fun ReminderDefaultPicker(
|
||||
title: String,
|
||||
presets: List<Int>,
|
||||
selected: CalendarReminderOverride,
|
||||
selected: ReminderOverride,
|
||||
allowInherit: Boolean,
|
||||
onSelect: (CalendarReminderOverride) -> Unit,
|
||||
onSelect: (ReminderOverride) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
leadTimeSummary: (@Composable (Int) -> String?)? = null,
|
||||
) {
|
||||
// Optimistic local state: once the user edits, the chosen override is
|
||||
// authoritative while the picker is open, so quick successive toggles compose
|
||||
@@ -158,9 +88,9 @@ fun ReminderDefaultPicker(
|
||||
LaunchedEffect(selected) {
|
||||
if (!userEdited) current = selected
|
||||
}
|
||||
val inherits = current is CalendarReminderOverride.Inherit
|
||||
val isNone = current is CalendarReminderOverride.None
|
||||
val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
||||
val inherits = current is ReminderOverride.Inherit
|
||||
val isNone = current is ReminderOverride.None
|
||||
val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty()
|
||||
// Custom (non-preset) lead times seen this session, so unchecking one keeps
|
||||
// its row (unchecked) until the picker closes instead of vanishing mid-tap,
|
||||
// which would strand a hand-entered value with no way to re-check it.
|
||||
@@ -173,7 +103,7 @@ fun ReminderDefaultPicker(
|
||||
var amountText by rememberSaveable { mutableStateOf("") }
|
||||
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||
|
||||
fun apply(override: CalendarReminderOverride) {
|
||||
fun apply(override: ReminderOverride) {
|
||||
userEdited = true
|
||||
current = override
|
||||
onSelect(override)
|
||||
@@ -197,7 +127,7 @@ fun ReminderDefaultPicker(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { apply(CalendarReminderOverride.Inherit) },
|
||||
onClick = { apply(ReminderOverride.Inherit) },
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
@@ -209,7 +139,7 @@ fun ReminderDefaultPicker(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { apply(CalendarReminderOverride.None) },
|
||||
onClick = { apply(ReminderOverride.None) },
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
val rowCount = rows.size + 1 // + the custom row
|
||||
@@ -217,6 +147,7 @@ fun ReminderDefaultPicker(
|
||||
val checked = minute in selectedMinutes
|
||||
GroupedRow(
|
||||
title = reminderLeadTimeLabel(minute),
|
||||
summary = leadTimeSummary?.invoke(minute),
|
||||
position = positionOf(index, rowCount),
|
||||
selected = checked,
|
||||
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
|
||||
@@ -232,8 +163,8 @@ fun ReminderDefaultPicker(
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = customExpanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
CustomReminderEditor(
|
||||
amountText = amountText,
|
||||
@@ -250,26 +181,6 @@ fun ReminderDefaultPicker(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The override a lead-time set maps to when emitted from [ReminderDefaultPicker].
|
||||
* A non-empty set is [CalendarReminderOverride.Minutes]; an empty set (the last
|
||||
* time was unchecked) reverts to [CalendarReminderOverride.Inherit] on a
|
||||
* per-calendar picker ([allowInherit]) so an accidental toggle-undo can't
|
||||
* silently wipe the calendar's default, and to explicit
|
||||
* [CalendarReminderOverride.None] on the global default (where empty legitimately
|
||||
* means no reminder). Pure so it can be unit-tested.
|
||||
*/
|
||||
internal fun reminderOverrideForMinutes(
|
||||
minutes: List<Int>,
|
||||
allowInherit: Boolean,
|
||||
): CalendarReminderOverride {
|
||||
val norm = minutes.distinct().sorted()
|
||||
return when {
|
||||
norm.isNotEmpty() -> CalendarReminderOverride.Minutes(norm)
|
||||
allowInherit -> CalendarReminderOverride.Inherit
|
||||
else -> CalendarReminderOverride.None
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
|
||||
@@ -287,89 +198,49 @@ private fun CustomReminderEditor(
|
||||
onConfirm: (Int) -> Unit,
|
||||
) {
|
||||
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
|
||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Unit toggle first so it stays visible above the keyboard once the
|
||||
// amount field (the bottom row) is focused and scrolled into view.
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
ReminderUnit.entries.forEachIndexed { index, entry ->
|
||||
SegmentedButton(
|
||||
selected = unit == entry,
|
||||
onClick = { onUnitChange(entry) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
|
||||
label = { Text(stringResource(reminderUnitLabel(entry))) },
|
||||
)
|
||||
}
|
||||
}
|
||||
// Amount, a live preview of the lead time it resolves to, and Set —
|
||||
// all on one row, sitting just above the keyboard.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DialogAmountField(
|
||||
value = amountText,
|
||||
onValueChange = onAmountChange,
|
||||
placeholder = "10",
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||
?: stringResource(R.string.reminder_custom_amount),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||
enabled = amount != null,
|
||||
) {
|
||||
Text(stringResource(R.string.reminder_custom_set))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CustomAmountEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = onAmountChange,
|
||||
unitLabels = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
|
||||
selectedUnit = unit.ordinal,
|
||||
onUnitChange = { onUnitChange(ReminderUnit.entries[it]) },
|
||||
preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||
?: stringResource(R.string.reminder_custom_amount),
|
||||
setLabel = stringResource(R.string.reminder_custom_set),
|
||||
confirmEnabled = amount != null,
|
||||
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||
)
|
||||
}
|
||||
|
||||
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||
/** A short explanatory paragraph shown under a picker's title, above the rows,
|
||||
* on the same left margin as the rows themselves. */
|
||||
@Composable
|
||||
private fun PickerDescription(text: String) {
|
||||
internal fun PickerDescription(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectedCheck() {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
|
||||
* options (today / this week / this month) and the rolling windows (next 7 / 30
|
||||
* days), with a "Custom" row that expands an inline day-count editor (1–365).
|
||||
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
|
||||
*
|
||||
* Every option carries the dates it resolves to today as its summary, computed
|
||||
* through the same [dayCount] the agenda windows by (hence [weekStart]) — only
|
||||
* the concrete span tells "This week" and "Next 7 days" apart.
|
||||
*/
|
||||
@Composable
|
||||
fun AgendaRangePicker(
|
||||
title: String,
|
||||
description: String,
|
||||
selected: AgendaRange,
|
||||
weekStart: DayOfWeek,
|
||||
onSelect: (AgendaRange) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
@@ -383,10 +254,21 @@ fun AgendaRangePicker(
|
||||
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
|
||||
}
|
||||
|
||||
val locale = currentLocale()
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
// The agenda's own end-day arithmetic (anchor + dayCount - 1), so the dates
|
||||
// match what it will show.
|
||||
val windowSummary: (AgendaRange) -> String = { range ->
|
||||
val end = today.plus(range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY)
|
||||
agendaRangeWindowSummary(range, today, end, locale, monthAsSpan = true)
|
||||
}
|
||||
|
||||
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
title = agendaRangeLabel(option),
|
||||
summary = windowSummary(option),
|
||||
position = position,
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
@@ -421,6 +303,9 @@ fun AgendaRangePicker(
|
||||
} else {
|
||||
stringResource(R.string.agenda_range_custom)
|
||||
},
|
||||
// An unset custom window has no day count yet, so the row stays
|
||||
// single-line until it does.
|
||||
summary = if (customSelected) windowSummary(selected) else null,
|
||||
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
|
||||
selected = customSelected,
|
||||
trailing = if (customSelected) {
|
||||
@@ -432,8 +317,8 @@ fun AgendaRangePicker(
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = customExpanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
CustomDaysEditor(
|
||||
amountText = amountText,
|
||||
@@ -456,41 +341,136 @@ private fun CustomDaysEditor(
|
||||
) {
|
||||
val days = amountText.toIntOrNull()
|
||||
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
CustomAmountEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = onAmountChange,
|
||||
placeholder = "30",
|
||||
preview = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
||||
?: stringResource(R.string.agenda_range_custom_hint),
|
||||
setLabel = stringResource(R.string.reminder_custom_set),
|
||||
confirmEnabled = days != null,
|
||||
onConfirm = { days?.let(onConfirm) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Snooze-duration picker, full-screen and **single-select**: the [presets]
|
||||
* (whole-minute delays) each sit as a checkmark row, with a "Custom" row that
|
||||
* expands an inline amount field plus a Minutes/Hours unit toggle to enter an
|
||||
* arbitrary delay. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking
|
||||
* a preset or confirming a custom value applies via [onSelect] and closes.
|
||||
* [label] renders a delay in minutes as a duration ("10 minutes", "1 hour") and
|
||||
* is reused for both the rows and the custom preview.
|
||||
*/
|
||||
@Composable
|
||||
fun SnoozeDurationPicker(
|
||||
title: String,
|
||||
presets: List<Int>,
|
||||
selected: Int,
|
||||
label: @Composable (Int) -> String,
|
||||
onSelect: (Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val customSelected = selected !in presets
|
||||
val rowCount = presets.size + 1 // + the custom row
|
||||
|
||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var amountText by rememberSaveable {
|
||||
mutableStateOf(if (customSelected) snoozeCustomAmount(selected).toString() else "")
|
||||
}
|
||||
var unit by rememberSaveable {
|
||||
mutableStateOf(if (customSelected) snoozeCustomUnit(selected) else ReminderUnit.Minutes)
|
||||
}
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
||||
presets.forEachIndexed { index, minute ->
|
||||
val isSelected = minute == selected
|
||||
GroupedRow(
|
||||
title = label(minute),
|
||||
position = positionOf(index, rowCount),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
onSelect(minute)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
// The Custom row connects downward into the editor card when expanded, so
|
||||
// the two read as one grouped container (the shared custom-expand pattern).
|
||||
GroupedRow(
|
||||
title = if (customSelected) label(selected) else stringResource(R.string.event_edit_reminder_custom),
|
||||
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
|
||||
selected = customSelected,
|
||||
trailing = if (customSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { customExpanded = !customExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = customExpanded,
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
DialogAmountField(
|
||||
value = amountText,
|
||||
onValueChange = onAmountChange,
|
||||
placeholder = "30",
|
||||
CustomSnoozeEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = { amountText = it },
|
||||
unit = unit,
|
||||
onUnitChange = { unit = it },
|
||||
label = label,
|
||||
onConfirm = { minutes ->
|
||||
onSelect(minutes)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
||||
?: stringResource(R.string.agenda_range_custom_hint),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { days?.let(onConfirm) },
|
||||
enabled = days != null,
|
||||
) {
|
||||
Text(stringResource(R.string.reminder_custom_set))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whole hours if the delay divides evenly, else minutes. */
|
||||
private fun snoozeCustomUnit(minutes: Int): ReminderUnit =
|
||||
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
|
||||
|
||||
private fun snoozeCustomAmount(minutes: Int): Int =
|
||||
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
|
||||
|
||||
/**
|
||||
* The expanded "Custom" snooze editor: a tonal card connected to the Custom row
|
||||
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
|
||||
* the delay it resolves to, and a tonal confirm enabled only for a valid 1–999
|
||||
* amount. [onConfirm] receives the final delay in minutes.
|
||||
*/
|
||||
@Composable
|
||||
private fun CustomSnoozeEditor(
|
||||
amountText: String,
|
||||
onAmountChange: (String) -> Unit,
|
||||
unit: ReminderUnit,
|
||||
onUnitChange: (ReminderUnit) -> Unit,
|
||||
label: @Composable (Int) -> String,
|
||||
onConfirm: (Int) -> Unit,
|
||||
) {
|
||||
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
|
||||
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||
CustomAmountEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = onAmountChange,
|
||||
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
|
||||
selectedUnit = units.indexOf(unit).coerceAtLeast(0),
|
||||
onUnitChange = { onUnitChange(units[it]) },
|
||||
preview = amount?.let { label(it * unit.minutesFactor) }
|
||||
?: stringResource(R.string.reminder_custom_amount),
|
||||
setLabel = stringResource(R.string.reminder_custom_set),
|
||||
confirmEnabled = amount != null,
|
||||
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||
)
|
||||
}
|
||||
|
||||
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
|
||||
@Composable
|
||||
fun agendaRangeLabel(range: AgendaRange): String = when (range) {
|
||||
|
||||
@@ -8,7 +8,16 @@ import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears
|
||||
import de.jeanlucmakiola.calendula.domain.upcomingOccurrences
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
@@ -94,6 +103,33 @@ fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule's first few dates as one line — "Next: 30 Jul, 6 Aug, 13 Aug" — for
|
||||
* the recurrence picker, where [recurrenceText]'s phrase says what the rule is
|
||||
* and this says what it does. Expands via [upcomingOccurrences] and inherits its
|
||||
* limits. A rule that yields nothing says so rather than showing an empty list.
|
||||
*/
|
||||
@Composable
|
||||
fun nextOccurrencesText(
|
||||
rule: SimpleRecurrence,
|
||||
start: kotlinx.datetime.LocalDate,
|
||||
locale: Locale,
|
||||
): String {
|
||||
val dates = rule.upcomingOccurrences(start, limit = NEXT_OCCURRENCE_COUNT)
|
||||
if (dates.isEmpty()) return stringResource(R.string.event_edit_recurrence_next_none)
|
||||
// Years only once they carry information (see [spansMultipleYears]).
|
||||
val pattern = if (occurrencesSpanYears(dates, start)) "dMMMy" else "dMMM"
|
||||
val formatter = localizedDateFormatter(locale, pattern)
|
||||
val formatted = dates.map { formatter.format(it.toJavaLocalDate()) }
|
||||
return stringResource(
|
||||
R.string.event_edit_recurrence_next,
|
||||
ListFormatter.getInstance(locale).format(formatted),
|
||||
)
|
||||
}
|
||||
|
||||
/** Enough dates to show a rhythm (and a skipped month), few enough for one line. */
|
||||
private const val NEXT_OCCURRENCE_COUNT = 3
|
||||
|
||||
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
||||
private fun rruleDayName(token: String, locale: Locale): String? {
|
||||
val dow = when (token.takeLast(2).uppercase()) {
|
||||
@@ -111,16 +147,36 @@ private fun rruleDayName(token: String, locale: Locale): String? {
|
||||
|
||||
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
||||
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
||||
val digits = raw.takeWhile { it.isDigit() }
|
||||
if (digits.length < 8) return null
|
||||
return try {
|
||||
val date = java.time.LocalDate.of(
|
||||
val date = untilLocalDate(raw) ?: return null
|
||||
return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
||||
}
|
||||
|
||||
private val UNTIL_UTC_FORMAT: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT)
|
||||
|
||||
/**
|
||||
* The calendar day an RRULE UNTIL value denotes, *in the device's zone*.
|
||||
*
|
||||
* The UTC form ("20261231T225959Z") must be converted back before its date is
|
||||
* read: `SimpleRecurrence.toRRule` writes the end of the chosen **local** day
|
||||
* expressed in UTC, so for zones behind UTC that instant already falls on the
|
||||
* following UTC date. Taking the leading digits straight off would then show
|
||||
* the day after the one the user picked. Date-only and floating forms are
|
||||
* already local and pass through unchanged.
|
||||
*/
|
||||
internal fun untilLocalDate(raw: String, zone: ZoneId = ZoneId.systemDefault()): LocalDate? {
|
||||
val value = raw.trim()
|
||||
return runCatching {
|
||||
LocalDateTime.parse(value, UNTIL_UTC_FORMAT)
|
||||
.atOffset(ZoneOffset.UTC)
|
||||
.atZoneSameInstant(zone)
|
||||
.toLocalDate()
|
||||
}.recoverCatching {
|
||||
val digits = value.takeWhile { it.isDigit() }
|
||||
LocalDate.of(
|
||||
digits.substring(0, 4).toInt(),
|
||||
digits.substring(4, 6).toInt(),
|
||||
digits.substring(6, 8).toInt(),
|
||||
)
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
@@ -5,18 +5,11 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||
|
||||
/** Common reminder lead times offered as quick picks in the form and settings. */
|
||||
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
|
||||
|
||||
/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */
|
||||
enum class ReminderUnit(val minutesFactor: Int) {
|
||||
Minutes(1),
|
||||
Hours(60),
|
||||
Days(1_440),
|
||||
Weeks(10_080),
|
||||
}
|
||||
|
||||
@StringRes
|
||||
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
||||
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */
|
||||
val ReorderableRowHeight: Dp = 64.dp
|
||||
private val RowGap: Dp = 2.dp
|
||||
|
||||
/**
|
||||
* A vertical list whose rows can be dragged into a new order by their handle.
|
||||
*
|
||||
* Built for the short, fixed grouped-card lists in Settings (#24) — no external
|
||||
* dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings
|
||||
* screens are a single [androidx.compose.foundation.verticalScroll] column, which
|
||||
* can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a
|
||||
* uniform gap, so a row's target slot is simply how many whole pitches it has
|
||||
* been dragged. The held row follows the finger while the others slide out of
|
||||
* its way (animated); on release the order is committed immediately with a
|
||||
* single [onReorder] call, then the held row eases into its new slot as a
|
||||
* purely visual settle — safe to interrupt with another drag, since the
|
||||
* commit itself never waits on it.
|
||||
*
|
||||
* [rowContent] receives the [Position] for the row's place in the order (to reuse
|
||||
* [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing)
|
||||
* and a `dragHandle` [Modifier] to attach to the element that starts a drag.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> ReorderableColumn(
|
||||
items: List<T>,
|
||||
keyOf: (T) -> Any,
|
||||
onReorder: (List<T>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit,
|
||||
) {
|
||||
val pitchPx = with(LocalDensity.current) { (ReorderableRowHeight + RowGap).toPx() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Local working copy; re-seeded when the incoming list changes (including the
|
||||
// echo of our own committed order).
|
||||
var order by remember(items) { mutableStateOf(items) }
|
||||
var draggedKey by remember { mutableStateOf<Any?>(null) }
|
||||
// Live translation of the held row from its slot (px); also drives the
|
||||
// release settle — re-based onto the row's new slot once the order commits
|
||||
// (see onDragEnd), then eased down to zero.
|
||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||
// The running release/cancel settle, cancelled if a new drag pre-empts it.
|
||||
// Purely visual — the order commit (see onDragEnd) never depends on it.
|
||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
||||
// Whole slots dragged → the slot the held row currently hovers over. Derived
|
||||
// so recomposition only fires when the *target slot* actually changes, not on
|
||||
// every dragged pixel: dragOffset moves every frame during a drag, but the
|
||||
// held row's own translation is already applied in the draw phase via
|
||||
// graphicsLayer below, so only the neighbours' shift (which depends on this)
|
||||
// needs to recompose, and only when a slot boundary is actually crossed.
|
||||
// (Read as a plain val, not `by`, below: a delegated property has a custom
|
||||
// getter and Kotlin won't smart-cast it in the `when` over `targetIndex` in
|
||||
// the loop, so the derived value is captured into a real local instead.)
|
||||
val targetIndex = remember(items, pitchPx) {
|
||||
derivedStateOf {
|
||||
val idx = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
||||
idx?.let { (it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex) }
|
||||
}
|
||||
}.value
|
||||
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) {
|
||||
order.forEachIndexed { index, item ->
|
||||
val key = keyOf(item)
|
||||
val isDragged = key == draggedKey
|
||||
|
||||
// Slide neighbours by one pitch to open the gap the held row will drop
|
||||
// into. Snap (not animate) once idle, so committing the new order — which
|
||||
// moves each row's slot — doesn't visibly fight a lingering animation.
|
||||
val shift = when {
|
||||
draggedIndex == null || targetIndex == null || isDragged -> 0f
|
||||
index in (draggedIndex + 1)..targetIndex -> -pitchPx
|
||||
index in targetIndex until draggedIndex -> pitchPx
|
||||
else -> 0f
|
||||
}
|
||||
val animatedShift by animateFloatAsState(
|
||||
targetValue = shift,
|
||||
animationSpec = if (draggedKey != null) spring(stiffness = Spring.StiffnessMediumLow) else snap(),
|
||||
label = "reorderShift",
|
||||
)
|
||||
|
||||
val dragHandle = Modifier.pointerInput(key) {
|
||||
detectDragGestures(
|
||||
onDragStart = {
|
||||
settleJob?.cancel()
|
||||
draggedKey = key
|
||||
dragOffset = 0f
|
||||
},
|
||||
onDrag = { change, amount ->
|
||||
change.consume()
|
||||
dragOffset += amount.y
|
||||
},
|
||||
onDragEnd = {
|
||||
val from = order.indexOfFirst { keyOf(it) == key }
|
||||
if (from < 0) return@detectDragGestures
|
||||
val to = (from + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
|
||||
if (to != from) {
|
||||
// Commit synchronously and unconditionally, before the settle
|
||||
// animation below runs — the commit must not depend on that
|
||||
// coroutine reaching its end, or a new drag starting within the
|
||||
// ~160ms settle window would cancel it and silently revert an
|
||||
// already-finished reorder.
|
||||
order = order.toMutableList().apply { add(to, removeAt(from)) }
|
||||
onReorder(order)
|
||||
// The row now lays out at slot `to` instead of `from`; re-base
|
||||
// the live offset onto that new slot (same visual position,
|
||||
// expressed relative to the new one) so the settle below eases
|
||||
// it the rest of the way instead of jumping.
|
||||
dragOffset -= (to - from) * pitchPx
|
||||
}
|
||||
settleJob = scope.launch {
|
||||
// Purely visual from here: ease the held row onto its slot, then
|
||||
// release the drag state. Safe to cancel — the order was already
|
||||
// committed above.
|
||||
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
|
||||
draggedKey = null
|
||||
dragOffset = 0f
|
||||
}
|
||||
},
|
||||
onDragCancel = {
|
||||
settleJob = scope.launch {
|
||||
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
|
||||
draggedKey = null
|
||||
dragOffset = 0f
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.height(ReorderableRowHeight)
|
||||
.zIndex(if (isDragged) 1f else 0f)
|
||||
.graphicsLayer {
|
||||
translationY = if (isDragged) dragOffset else animatedShift
|
||||
if (isDragged) {
|
||||
scaleX = 1.02f
|
||||
scaleY = 1.02f
|
||||
shadowElevation = 8.dp.toPx()
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
clip = false
|
||||
}
|
||||
},
|
||||
) {
|
||||
rowContent(item, positionOf(index, order.size), dragHandle, isDragged)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
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.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
||||
import de.jeanlucmakiola.calendula.domain.filterTimeZones
|
||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
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 de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Full-screen zone picker: a query field over every zone the JVM knows, with the
|
||||
* device zone and recently-picked ones pinned on top so the common case needs no
|
||||
* typing. [selected] is the pinned zone id, or null when the event follows the
|
||||
* device — picking the device row reports null back, which is what keeps an
|
||||
* unpinned event unpinned rather than freezing it to today's zone.
|
||||
*
|
||||
* Unlike the kit's [de.jeanlucmakiola.floret.components.OptionPicker], this
|
||||
* drives its own [LazyColumn] (hence `scrollable = false`): ~600 options is far
|
||||
* past what an eagerly composed column can carry.
|
||||
*/
|
||||
@Composable
|
||||
fun TimeZonePickerDialog(
|
||||
selected: String?,
|
||||
deviceZoneId: String,
|
||||
recents: List<String>,
|
||||
onSelect: (String?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
val locale = currentLocale()
|
||||
// ~600 zones, each resolving a localized name and up to two ICU short names:
|
||||
// far too much to run inside composition, so build it on a worker and let the
|
||||
// list fill in a frame later. Filtering the built catalogue is cheap (its
|
||||
// search keys are pre-normalized), so that stays here.
|
||||
val allZones by produceState(initialValue = emptyList<TimeZoneOption>(), locale) {
|
||||
value = withContext(Dispatchers.Default) {
|
||||
timeZoneOptions(locale, regionOf = ::icuTimeZoneRegion)
|
||||
}
|
||||
}
|
||||
val matches = remember(allZones, query) { filterTimeZones(allZones, query) }
|
||||
val recentZones = remember(allZones, recents) {
|
||||
recents.mapNotNull { id -> allZones.firstOrNull { it.id == id } }
|
||||
}
|
||||
// Resolved on its own rather than looked up in the catalogue: it's one zone,
|
||||
// so it costs nothing, and the device row can then render complete on the
|
||||
// first frame instead of showing a bare id until the catalogue lands.
|
||||
val deviceSummary = remember(deviceZoneId, locale) {
|
||||
timeZoneOptionOf(deviceZoneId, locale, regionOf = ::icuTimeZoneRegion)
|
||||
?.let { zoneDescriptor(it) }
|
||||
?: deviceZoneId
|
||||
}
|
||||
val searching = query.isNotBlank()
|
||||
|
||||
fun choose(zoneId: String?) {
|
||||
onSelect(zoneId)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.event_detail_timezone),
|
||||
onDismiss = onDismiss,
|
||||
scrollable = false,
|
||||
) {
|
||||
SearchField(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
|
||||
if (!searching) {
|
||||
// The device row is the "unpinned" choice, so it reads as a mode
|
||||
// rather than as a zone among zones — it stays out of the big
|
||||
// list and never carries an offset.
|
||||
item(key = "device") {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.event_edit_timezone_device),
|
||||
summary = deviceSummary,
|
||||
position = Position.Alone,
|
||||
selected = selected == null,
|
||||
leading = { Icon(Icons.Default.Public, contentDescription = null) },
|
||||
trailing = if (selected == null) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { choose(null) },
|
||||
)
|
||||
}
|
||||
if (recentZones.isNotEmpty()) {
|
||||
item(key = "recent_header") {
|
||||
SectionHeader(stringResource(R.string.event_edit_timezone_recent))
|
||||
}
|
||||
itemsIndexed(
|
||||
items = recentZones,
|
||||
key = { _, zone -> "recent_${zone.id}" },
|
||||
) { index, zone ->
|
||||
ZoneRow(
|
||||
zone = zone,
|
||||
position = positionOf(index, recentZones.size),
|
||||
selected = zone.id == selected,
|
||||
onClick = { choose(zone.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item(key = "all_header") {
|
||||
SectionHeader(stringResource(R.string.event_edit_timezone_all))
|
||||
}
|
||||
}
|
||||
|
||||
// allZones.isNotEmpty() gates this: until the catalogue lands there is
|
||||
// simply nothing to match yet, and claiming "no time zone matches"
|
||||
// for that frame would be wrong.
|
||||
if (searching && matches.isEmpty() && allZones.isNotEmpty()) {
|
||||
item(key = "empty") {
|
||||
Text(
|
||||
text = stringResource(R.string.event_edit_timezone_none, query),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp, vertical = 48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
items = matches,
|
||||
key = { _, zone -> "zone_${zone.id}" },
|
||||
) { index, zone ->
|
||||
ZoneRow(
|
||||
zone = zone,
|
||||
position = positionOf(index, matches.size),
|
||||
selected = zone.id == selected,
|
||||
onClick = { choose(zone.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The query box: the family's borderless [InlineTextField] over a tonal surface,
|
||||
* so it reads like the rest of the app's inputs rather than a boxed Material
|
||||
* field. The clear button rides inside the surface (unlike the search screen,
|
||||
* which has a top bar to put it in) because the picker's bar is the title.
|
||||
*/
|
||||
@Composable
|
||||
private fun SearchField(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
// surfaceContainerHighest — the picker sits on surfaceContainerHigh, so
|
||||
// anything lower vanishes into it.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
InlineTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = stringResource(R.string.event_edit_timezone_search),
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Search,
|
||||
onImeAction = { keyboard?.hide() },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||
)
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.search_clear),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A small primary-coloured group label, matching the settings screens. */
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 16.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun ZoneRow(
|
||||
zone: TimeZoneOption,
|
||||
position: Position,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = zone.label,
|
||||
summary = zoneDescriptor(zone),
|
||||
position = position,
|
||||
selected = selected,
|
||||
trailing = if (selected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.icu.util.TimeZone as IcuTimeZone
|
||||
|
||||
/**
|
||||
* The ISO 3166 region an IANA zone belongs to ("America/New_York" -> "US"), or
|
||||
* null when ICU has none or maps it to the multi-country "001" ("Etc/UTC").
|
||||
*
|
||||
* This is the `android.icu`-backed bridge the pure-JVM
|
||||
* [de.jeanlucmakiola.calendula.domain.timeZoneOptions] takes as `regionOf`, so
|
||||
* it can ask ICU for a zone's abbreviation in that zone's own region — the only
|
||||
* region for which ICU will surface it (see `resolveAbbreviation`). Lives here,
|
||||
* not in `domain/`, precisely because it needs an Android import.
|
||||
*/
|
||||
fun icuTimeZoneRegion(id: String): String? =
|
||||
runCatching { IcuTimeZone.getRegion(id) }.getOrNull()?.takeIf { it.length == 2 }
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Today
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* The top-bar "jump to today" icon button, shared by every calendar view's app
|
||||
* bar (#60). Shown in place of the fade-in FAB pill when the user moves the
|
||||
* today control into the toolbar; renders nothing when [show] is false, so it
|
||||
* drops straight into an app bar's `actions` slot without a wrapping condition.
|
||||
*/
|
||||
@Composable
|
||||
fun TodayAction(show: Boolean, onToday: () -> Unit) {
|
||||
if (!show) return
|
||||
IconButton(onClick = onToday) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Today,
|
||||
contentDescription = stringResource(R.string.today_jump_action),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||
|
||||
/**
|
||||
* A deliberately minimal, standalone surface for a captured crash report.
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.crash
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* Asks the user to send a captured crash report as an issue. The full report is
|
||||
* shown verbatim in a scrollable panel — the user sees exactly what will leave
|
||||
* the device before choosing to share it (the privacy backstop). [onSend] hands
|
||||
* off to [submitCrashReport]; [onDismiss] declines.
|
||||
*/
|
||||
@Composable
|
||||
fun CrashReportDialog(
|
||||
report: String,
|
||||
onSend: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(Icons.Default.BugReport, contentDescription = null) },
|
||||
title = { Text(stringResource(R.string.crash_dialog_title)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.crash_dialog_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = report,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.heightIn(max = 220.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onSend) { Text(stringResource(R.string.crash_dialog_report)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.crash_dialog_dismiss)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.crash
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.core.net.toUri
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* Hand the captured crash report off to the user's chosen channel: the report
|
||||
* is copied to the clipboard (the reliable path for a full stack trace) and the
|
||||
* project's Gitea "new issue" page is opened with the body prefilled. Nothing is
|
||||
* sent automatically — the app has no network access; the user reviews and
|
||||
* submits the issue themselves.
|
||||
*/
|
||||
fun submitCrashReport(context: Context, report: String) {
|
||||
copyReportToClipboard(context, report)
|
||||
val opened = runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, buildIssueUri(context, report)))
|
||||
}.isSuccess
|
||||
val message = if (opened) R.string.crash_report_copied else R.string.crash_report_open_failed
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
/** Open the issue tracker's template chooser for a manual (non-crash) report. */
|
||||
fun openIssueTracker(context: Context) {
|
||||
val uri = context.getString(R.string.report_issue_choose_url).toUri()
|
||||
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) }
|
||||
}
|
||||
|
||||
private fun copyReportToClipboard(context: Context, report: String) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
||||
val label = context.getString(R.string.crash_report_clip_label)
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, report))
|
||||
}
|
||||
|
||||
/**
|
||||
* The Gitea `issues/new` URL with `title` and `body` prefilled. A full report
|
||||
* can blow past URL-length limits, so an over-long one is left out of the link
|
||||
* (with a "paste from clipboard" placeholder) — the clipboard copy is the
|
||||
* source of truth in that case.
|
||||
*/
|
||||
private fun buildIssueUri(context: Context, report: String) =
|
||||
context.getString(R.string.report_issue_url).toUri().buildUpon()
|
||||
.appendQueryParameter("title", context.getString(R.string.crash_report_issue_title))
|
||||
.appendQueryParameter("body", buildIssueBody(context, report))
|
||||
.build()
|
||||
|
||||
private fun buildIssueBody(context: Context, report: String): String {
|
||||
val block = if (report.length > MAX_URL_REPORT_CHARS) {
|
||||
context.getString(R.string.crash_report_body_paste)
|
||||
} else {
|
||||
"```\n$report\n```"
|
||||
}
|
||||
return context.getString(R.string.crash_report_body_template, block)
|
||||
}
|
||||
|
||||
/** Keep the prefilled body comfortably under common URL-length ceilings. */
|
||||
private const val MAX_URL_REPORT_CHARS = 6_000
|
||||
@@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -43,7 +42,6 @@ import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -68,8 +66,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
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
|
||||
@@ -77,11 +77,14 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||
@@ -91,12 +94,18 @@ import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val HOUR_HEIGHT = 56.dp
|
||||
private val GUTTER_WIDTH = 48.dp
|
||||
/** Start inset for the gutter's hour labels so they centre on the top bar's
|
||||
* hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's
|
||||
* 4dp inset + 24dp half icon button), matching the week view. */
|
||||
private val GUTTER_CONTENT_START_INSET = 8.dp
|
||||
private val MIN_EVENT_HEIGHT = 24.dp
|
||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||
@@ -119,6 +128,7 @@ fun DayScreen(
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
todayInToolbar: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
initialDateIso: String? = null,
|
||||
viewModel: DayViewModel = hiltViewModel(),
|
||||
@@ -151,6 +161,13 @@ fun DayScreen(
|
||||
else -> true
|
||||
}
|
||||
|
||||
// Drives whether the title carries the year. Falls back to the clock only
|
||||
// while the first load is in flight, when there is no state to read today from.
|
||||
val currentYear = when (val s = state) {
|
||||
is DayUiState.Success -> s.today.year
|
||||
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
||||
}
|
||||
|
||||
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||
@@ -199,16 +216,19 @@ fun DayScreen(
|
||||
topBar = {
|
||||
DayTopBar(
|
||||
date = date,
|
||||
currentYear = currentYear,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = jumpToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnToday,
|
||||
todayVisible = !isOnToday && !todayInToolbar,
|
||||
todayText = stringResource(R.string.day_today_action),
|
||||
onToday = jumpToToday,
|
||||
onCreate = { onCreateEvent(date, null) },
|
||||
@@ -245,8 +265,6 @@ private fun DayContent(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val threshold = with(density) { 24.dp.toPx() }
|
||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
@@ -275,20 +293,7 @@ private fun DayContent(
|
||||
// Whole-page horizontal swipe, one level above the timeline's vertical
|
||||
// scroll: a horizontal drag crosses this detector's slop, while a vertical
|
||||
// drag is consumed by the inner scroll first — the two gestures coexist.
|
||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { dragAccum = 0f },
|
||||
onDragEnd = {
|
||||
when {
|
||||
dragAccum < -threshold -> onSwipeNext()
|
||||
dragAccum > threshold -> onSwipePrev()
|
||||
}
|
||||
dragAccum = 0f
|
||||
},
|
||||
onDragCancel = { dragAccum = 0f },
|
||||
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
||||
)
|
||||
}
|
||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
@@ -354,16 +359,20 @@ private fun DaySuccess(
|
||||
@Composable
|
||||
private fun DayTopBar(
|
||||
date: LocalDate,
|
||||
currentYear: Int,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = formatDayTitle(date),
|
||||
text = formatDayTitle(date, locale, currentYear),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
@@ -376,6 +385,7 @@ private fun DayTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TodayAction(show = showTodayButton, onToday = onToday)
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
@@ -448,9 +458,11 @@ private fun AllDayBar(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val soften = LocalSoftenColors.current
|
||||
val fill = eventFill(event.color, dark, soften)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||
.background(fill, RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = title },
|
||||
@@ -461,7 +473,7 @@ private fun AllDayBar(
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.8f),
|
||||
color = eventInk(fill),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -484,10 +496,12 @@ private fun Timeline(
|
||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||
// soft corners are permanent at any scroll position.
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
// Hour gutter (scrolls in sync with the day column)
|
||||
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
||||
// labels centre on the top bar hamburger, matching the week view.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(GUTTER_WIDTH)
|
||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
@@ -610,9 +624,11 @@ private fun EventBlock(
|
||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||
minToHm(block.endMin, use24Hour, locale)
|
||||
val showTime = block.endMin - block.startMin >= 45
|
||||
val soften = LocalSoftenColors.current
|
||||
val fill = eventFill(block.event.color, dark, soften)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
||||
.background(fill, RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = "$title, $timeLabel" },
|
||||
@@ -623,7 +639,7 @@ private fun EventBlock(
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = if (showTime) 1 else 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.85f),
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
)
|
||||
if (showTime) {
|
||||
Text(
|
||||
@@ -631,7 +647,7 @@ private fun EventBlock(
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.6f),
|
||||
color = eventInk(fill, alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -657,10 +673,10 @@ private fun DayLoading() {
|
||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||
formatMinuteOfDay(min, is24Hour, locale)
|
||||
|
||||
private fun formatDayTitle(date: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
return "$weekday, ${date.day}. $monthName ${date.year}"
|
||||
}
|
||||
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
|
||||
formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
|
||||
locale = locale,
|
||||
currentYear = currentYear,
|
||||
skeleton = "EEEdMMM",
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
@@ -87,25 +88,31 @@ import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
||||
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.floret.components.OptionCard
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlin.time.toJavaInstant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
@@ -116,7 +123,10 @@ import kotlin.time.Instant
|
||||
* top-bar arrow both return to the calendar. Events in writable calendars can
|
||||
* be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared
|
||||
* event form for this occurrence — for recurring events the form asks how
|
||||
* far the change reaches when saving.
|
||||
* far the change reaches when saving. [onDuplicate] opens the same form
|
||||
* seeded with a copy of this event as a new, unsaved event (#52) — offered
|
||||
* for any event, including read-only ones, since the copy lands in an
|
||||
* editable calendar.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -126,6 +136,7 @@ fun EventDetailScreen(
|
||||
endMillis: Long,
|
||||
onBack: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDuplicate: (EventForm) -> Unit,
|
||||
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||
@@ -160,15 +171,14 @@ fun EventDetailScreen(
|
||||
}
|
||||
|
||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||
// upgrade in place. Granting continues straight into the tapped action.
|
||||
var pendingEdit by remember { mutableStateOf(false) }
|
||||
// upgrade in place. Granting continues straight into the tapped action —
|
||||
// edit, delete, or duplicate — held here until the result comes back.
|
||||
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
if (pendingEdit) onEdit() else showDeleteDialog = true
|
||||
}
|
||||
pendingEdit = false
|
||||
if (granted) pendingWrite?.invoke()
|
||||
pendingWrite = null
|
||||
}
|
||||
val hasWritePermission = {
|
||||
ContextCompat.checkSelfPermission(
|
||||
@@ -176,21 +186,20 @@ fun EventDetailScreen(
|
||||
Manifest.permission.WRITE_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
val onDeleteClick = {
|
||||
val requireWrite: (() -> Unit) -> Unit = { action ->
|
||||
if (hasWritePermission()) {
|
||||
showDeleteDialog = true
|
||||
action()
|
||||
} else {
|
||||
pendingEdit = false
|
||||
pendingWrite = action
|
||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
}
|
||||
val onEditClick = {
|
||||
if (hasWritePermission()) {
|
||||
onEdit()
|
||||
} else {
|
||||
pendingEdit = true
|
||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
|
||||
val onEditClick = { requireWrite(onEdit) }
|
||||
// Duplicate reads the loaded detail into a create form and hands it up; the
|
||||
// create still needs WRITE_CALENDAR even when the source is read-only.
|
||||
val onDuplicateClick = {
|
||||
requireWrite { viewModel.duplicateForm()?.let(onDuplicate) }
|
||||
}
|
||||
|
||||
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
||||
@@ -229,7 +238,8 @@ fun EventDetailScreen(
|
||||
},
|
||||
actions = {
|
||||
val s = state
|
||||
// Share works for any loaded event — it only reads the event.
|
||||
// Share and duplicate work for any loaded event — both only
|
||||
// read it; the duplicate is created into a writable calendar.
|
||||
if (s is EventDetailUiState.Success) {
|
||||
IconButton(onClick = onShareClick) {
|
||||
Icon(
|
||||
@@ -237,6 +247,15 @@ fun EventDetailScreen(
|
||||
contentDescription = stringResource(R.string.event_detail_share),
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onDuplicateClick,
|
||||
enabled = deleteState != DeleteUiState.Deleting,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
contentDescription = stringResource(R.string.event_detail_duplicate),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Edit/delete need a writable calendar — WebCal subscriptions,
|
||||
// birthday calendars etc. are read-only at the provider level.
|
||||
@@ -367,7 +386,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
val instance = detail.instance
|
||||
val dark = isSystemInDarkTheme()
|
||||
val locale = currentDetailLocale()
|
||||
val accent = pastelize(instance.color, dark)
|
||||
val accent = eventFill(instance.color, dark, LocalSoftenColors.current)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
@@ -438,14 +457,44 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
}
|
||||
|
||||
// Time zone — only when the event is timed and pinned to a zone other
|
||||
// than the device's, so cross-zone events read unambiguously.
|
||||
foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel ->
|
||||
// than the device's, so cross-zone events read unambiguously. It answers
|
||||
// "when was it set", which the zone's name alone never did: an 8 AM New
|
||||
// York call showing as 2 PM here should still say 8 AM somewhere.
|
||||
//
|
||||
// Same hierarchy as the When card above — the label carries the card,
|
||||
// the time sits small beneath it. The local time is the one the reader
|
||||
// acts on, so the original must stay quieter than it, not compete.
|
||||
val foreignZone = remember(detail.eventTimezone, instance.isAllDay, instance.start, locale) {
|
||||
foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale, instance.start)
|
||||
}
|
||||
foreignZone?.let { zoneOption ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.Default.Public,
|
||||
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
||||
) {
|
||||
Text(text = tzLabel, style = MaterialTheme.typography.titleMedium)
|
||||
// The id is the primary label ("Europe/Berlin"); the long
|
||||
// localized name spelled next to it made the field too wide.
|
||||
Text(text = zoneOption.label, style = MaterialTheme.typography.titleMedium)
|
||||
// Beneath, small: the abbreviation plus the event's own-zone time,
|
||||
// e.g. "CET · 8:00 AM – 9:00 AM". formatWhen puts the range in the
|
||||
// secondary half only for a same-day event; one spanning midnight
|
||||
// — which a cross-zone event easily does — carries the whole span
|
||||
// in the primary instead, so fall back to it. If the zone can't be
|
||||
// read back at all, drop to just the abbreviation + offset.
|
||||
val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull()
|
||||
?.let { formatWhen(instance, it, locale) }
|
||||
?.let { (primary, secondary) -> secondary ?: primary }
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = if (originalTime != null) {
|
||||
"${zoneOption.shortName} · $originalTime"
|
||||
} else {
|
||||
zoneDescriptor(zoneOption)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -737,21 +786,30 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
|
||||
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
|
||||
|
||||
/**
|
||||
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
||||
* but only when the event is timed and pinned to a zone different from the
|
||||
* device's. Returns null when there's nothing worth showing.
|
||||
* [tz] resolved as a [TimeZoneOption], but only when the event is timed and
|
||||
* pinned to a zone different from the device's — the cases where showing it
|
||||
* removes ambiguity. Null otherwise (all-day, device-zone, blank, or an id the
|
||||
* tz database doesn't know).
|
||||
*
|
||||
* Resolved *at [at]*, the event's own start, not at "now": the abbreviation and
|
||||
* offset both move with DST, and the card prints them next to the event's time.
|
||||
* Resolving at now would label a July event "CET · 10:00 AM" when read in
|
||||
* January — an abbreviation that contradicts the time beside it.
|
||||
*/
|
||||
private fun foreignTimeZoneLabel(tz: String?, isAllDay: Boolean, locale: Locale): String? {
|
||||
private fun foreignTimeZone(
|
||||
tz: String?,
|
||||
isAllDay: Boolean,
|
||||
locale: Locale,
|
||||
at: Instant,
|
||||
): TimeZoneOption? {
|
||||
if (isAllDay || tz.isNullOrBlank()) return null
|
||||
val deviceZone = ZoneId.systemDefault().id
|
||||
if (tz == deviceZone) return null
|
||||
return try {
|
||||
val zone = ZoneId.of(tz)
|
||||
val name = zone.getDisplayName(JavaTextStyle.FULL, locale)
|
||||
if (name == tz) tz else "$name ($tz)"
|
||||
} catch (e: Exception) {
|
||||
tz
|
||||
}
|
||||
if (tz == ZoneId.systemDefault().id) return null
|
||||
return timeZoneOptionOf(
|
||||
tz,
|
||||
locale,
|
||||
at = at.toJavaInstant(),
|
||||
regionOf = ::icuTimeZoneRegion,
|
||||
)
|
||||
}
|
||||
|
||||
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
|
||||
|
||||
@@ -8,10 +8,12 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.time.Clock
|
||||
@@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
@@ -138,16 +141,48 @@ class EventDetailViewModel @Inject constructor(
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a create form seeded from the open event so the user can save a
|
||||
* copy as a new, independent event (#52). Returns null when nothing is
|
||||
* loaded.
|
||||
*
|
||||
* The recurrence is dropped — a duplicate is a single event; anyone who
|
||||
* wants a series can re-add one in the edit form that opens. The source
|
||||
* calendar is kept only when it's writable, otherwise the id is cleared so
|
||||
* the create resolves to the last-used/first-writable calendar — which lets
|
||||
* a read-only event (a WebCal subscription, a birthday) be copied into an
|
||||
* editable calendar. The occurrence's own times carry over unchanged.
|
||||
*/
|
||||
fun duplicateForm(): EventForm? {
|
||||
val loaded = state.value as? EventDetailUiState.Success ?: return null
|
||||
val detail = loaded.detail
|
||||
return detail.toEditForm(
|
||||
beginMillis = detail.instance.start.toEpochMilliseconds(),
|
||||
endMillis = detail.instance.end.toEpochMilliseconds(),
|
||||
zone = TimeZone.currentSystemDefault(),
|
||||
).copy(
|
||||
rrule = null,
|
||||
calendarId = detail.instance.calendarId.takeIf { loaded.canModify },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||
val detail = repository.eventDetail(target.eventId)
|
||||
// The Events row holds the series start; replace it with this
|
||||
// occurrence's time so recurring events render correctly.
|
||||
val corrected = detail.copy(
|
||||
instance = detail.instance.copy(
|
||||
start = Instant.fromEpochMilliseconds(target.beginMillis),
|
||||
end = Instant.fromEpochMilliseconds(target.endMillis),
|
||||
),
|
||||
)
|
||||
// occurrence's time so recurring events render correctly. An external
|
||||
// "open event" that names no occurrence ([NO_OCCURRENCE_TIME] — e.g. a
|
||||
// bare content://.../events/<id> VIEW intent, issue #48) keeps the row's
|
||||
// own DTSTART/DTEND instead of overriding it to the epoch.
|
||||
val corrected = if (target.beginMillis == NO_OCCURRENCE_TIME) {
|
||||
detail
|
||||
} else {
|
||||
detail.copy(
|
||||
instance = detail.instance.copy(
|
||||
start = Instant.fromEpochMilliseconds(target.beginMillis),
|
||||
end = Instant.fromEpochMilliseconds(target.endMillis),
|
||||
),
|
||||
)
|
||||
}
|
||||
val calendar = repository.calendars().first()
|
||||
.firstOrNull { it.id == corrected.instance.calendarId }
|
||||
EventDetailUiState.Success(
|
||||
@@ -168,6 +203,16 @@ class EventDetailViewModel @Inject constructor(
|
||||
|
||||
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
||||
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Sentinel begin/end for an "open this event" that names no occurrence —
|
||||
* a bare `content://com.android.calendar/events/<id>` VIEW intent with no
|
||||
* `EXTRA_EVENT_BEGIN_TIME` (issue #48). [loadDetail] then keeps the event
|
||||
* row's own DTSTART/DTEND instead of overriding it to the epoch.
|
||||
*/
|
||||
const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,8 @@ data class EventEditUiState(
|
||||
* neither list.
|
||||
*/
|
||||
val hiddenFields: List<EventFormField> = emptyList(),
|
||||
/** Recently picked zones, most recent first — the zone picker's shortlist. */
|
||||
val recentTimeZones: List<String> = emptyList(),
|
||||
/** True while editing an existing event (the calendar is then fixed). */
|
||||
val isEditing: Boolean = false,
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
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.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
@@ -18,9 +19,11 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||
import de.jeanlucmakiola.calendula.domain.problems
|
||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -38,6 +41,8 @@ import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
@@ -50,6 +55,36 @@ import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Where a prefilled [EventEditViewModel.openImported] form came from — the two
|
||||
* sources want different reminder handling (#49).
|
||||
*/
|
||||
enum class ImportSource {
|
||||
/**
|
||||
* An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps).
|
||||
* It carries no reminders of its own, so the settings default is applied
|
||||
* automatically, exactly like an in-app new event.
|
||||
*/
|
||||
Insert,
|
||||
|
||||
/**
|
||||
* A parsed single-event `.ics` file. Its own reminders are respected; the
|
||||
* settings default is offered through [EventEditViewModel.importReminderPrompt]
|
||||
* rather than silently applied or suppressed.
|
||||
*/
|
||||
File,
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending offer to swap an imported `.ics` event's reminders for the settings
|
||||
* default (#49). [currentReminderCount] is what the file carried (0 or more);
|
||||
* [defaultReminders] is what accepting would set.
|
||||
*/
|
||||
data class ImportReminderPrompt(
|
||||
val currentReminderCount: Int,
|
||||
val defaultReminders: List<Int>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Holds the event form being composed. The form's calendar id resolves to
|
||||
* (user pick > last used > first writable); the resolved value is what the UI
|
||||
@@ -78,10 +113,16 @@ class EventEditViewModel @Inject constructor(
|
||||
// freezes the auto-applied default: switching calendars no longer overwrites
|
||||
// their choice. Reset with the form.
|
||||
private val _remindersTouched = MutableStateFlow(false)
|
||||
// A one-time offer, raised when a .ics import opens, to replace the file's
|
||||
// reminders with the settings default (#49). Null while there's nothing to ask.
|
||||
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
|
||||
|
||||
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||
|
||||
/** Pending "apply your default reminder?" offer for a `.ics` import; null when none. */
|
||||
val importReminderPrompt: StateFlow<ImportReminderPrompt?> = _importReminderPrompt.asStateFlow()
|
||||
|
||||
/**
|
||||
* The event being edited plus everything the form saw at load time.
|
||||
* For recurring events the write scope is chosen at save time; the
|
||||
@@ -112,7 +153,17 @@ class EventEditViewModel @Inject constructor(
|
||||
val allDay: List<Int>,
|
||||
val timedOverrides: Map<Long, List<Int>>,
|
||||
val allDayOverrides: Map<Long, List<Int>>,
|
||||
)
|
||||
) {
|
||||
/** The default reminders for an event on [calendarId] of the given kind. */
|
||||
fun resolveFor(calendarId: Long?, isAllDay: Boolean): List<Int> = resolveDefaultReminder(
|
||||
timedGlobal = timed,
|
||||
allDayGlobal = allDay,
|
||||
timedOverrides = timedOverrides,
|
||||
allDayOverrides = allDayOverrides,
|
||||
calendarId = calendarId,
|
||||
isAllDay = isAllDay,
|
||||
)
|
||||
}
|
||||
|
||||
private data class ExternalInputs(
|
||||
val writable: List<CalendarSource>,
|
||||
@@ -127,18 +178,16 @@ class EventEditViewModel @Inject constructor(
|
||||
repository.calendars().catch { emit(emptyList()) }
|
||||
|
||||
/**
|
||||
* Writable calendars — the only valid event targets. Disabled calendars are
|
||||
* excluded, so you can't create into a calendar you've removed from the app;
|
||||
* a last-used preselect landing on a now-disabled calendar falls back to the
|
||||
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
|
||||
* Managed special-dates calendars are excluded too: their events are owned by
|
||||
* the contact sync, which would delete any user event created there.
|
||||
* The calendars a new event can be saved to ([isEventTarget]): writable,
|
||||
* switched on, not a contact-filled mirror, not a non-syncing subscription.
|
||||
* A last-used preselect landing on an excluded calendar falls back to the
|
||||
* first remaining one (handled by [resolvedCalendarId] and [state]).
|
||||
*
|
||||
* This is the list of *targets*. An event already living in an excluded
|
||||
* calendar keeps it — [state] adds it back to the picker.
|
||||
*/
|
||||
private val writableCalendars: Flow<List<CalendarSource>> = combine(
|
||||
allCalendars,
|
||||
prefs.disabledCalendarIds,
|
||||
) { calendars, disabled ->
|
||||
calendars.filter { it.canModifyContents && it.id !in disabled && !it.isManaged }
|
||||
private val writableCalendars: Flow<List<CalendarSource>> = allCalendars.map { calendars ->
|
||||
calendars.filter { it.isEventTarget }
|
||||
}
|
||||
|
||||
/** The target calendar id, resolved exactly as the form shows it. */
|
||||
@@ -172,7 +221,8 @@ class EventEditViewModel @Inject constructor(
|
||||
).flowOn(io),
|
||||
colorPalette,
|
||||
allCalendars,
|
||||
) { local, external, palette, allCalendars ->
|
||||
settingsPrefs.recentTimeZones.flowOn(io),
|
||||
) { local, external, palette, allCalendars, recentTimeZones ->
|
||||
val form = local.form ?: return@combine null
|
||||
val resolvedId = form.calendarId
|
||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||
@@ -183,19 +233,27 @@ class EventEditViewModel @Inject constructor(
|
||||
// off the calendar's durable marker, not a stored id, so it holds after a
|
||||
// backup restore too.
|
||||
val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true
|
||||
// The picker offers writable calendars only; when editing a managed event
|
||||
// its own (excluded) calendar is added back so the row still names it.
|
||||
// The event's own calendar is added back whenever it isn't a target
|
||||
// (managed, or switched off), so the row keeps naming it and saving can
|
||||
// leave the event where it is. A read-only one is still no target.
|
||||
val ownCalendar = resolvedCalendar?.takeIf { own ->
|
||||
own.canModifyContents && external.writable.none { it.id == own.id }
|
||||
}
|
||||
val pickerCalendars =
|
||||
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
||||
else external.writable
|
||||
val visibleFields = external.defaultFields + local.revealed
|
||||
if (ownCalendar != null) external.writable + ownCalendar else external.writable
|
||||
// An all-day event is date-anchored, so a zone is meaningless on it —
|
||||
// the field is withheld from both lists rather than shown as a no-op.
|
||||
val offerableFields = EventFormField.entries.toSet() -
|
||||
if (resolved.isAllDay) setOf(EventFormField.Timezone) else emptySet()
|
||||
val visibleFields = (external.defaultFields + local.revealed) intersect offerableFields
|
||||
EventEditUiState(
|
||||
form = resolved,
|
||||
calendars = pickerCalendars,
|
||||
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||
saveState = local.saveState,
|
||||
visibleFields = visibleFields,
|
||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||
hiddenFields = (offerableFields - visibleFields).sorted(),
|
||||
recentTimeZones = recentTimeZones,
|
||||
isEditing = local.editTarget != null,
|
||||
isManaged = isManaged,
|
||||
autofocusTitle = external.autofocusTitle,
|
||||
@@ -213,6 +271,13 @@ class EventEditViewModel @Inject constructor(
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* First day of the week for ordering the recurrence weekday toggles — the
|
||||
* app's "week starts on" preference, matching the calendar views (Auto
|
||||
* falls back to the locale convention).
|
||||
*/
|
||||
val firstDayOfWeek: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||
|
||||
/**
|
||||
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
|
||||
* from midnight) anchors the start when the form is opened by tapping a slot
|
||||
@@ -241,18 +306,39 @@ class EventEditViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a fresh event from a parsed `.ics` file (the single-event "open into
|
||||
* the create form" path). [form] already carries the file's fields; its
|
||||
* [EventForm.calendarId] is null so the calendar still resolves to the
|
||||
* last-used/first-writable one, and reminders are frozen as touched so the
|
||||
* settings default never overwrites what the file specified. No-op when a
|
||||
* form is already open, so the prefill survives configuration changes.
|
||||
* Seed a fresh event from a prefilled [form] — a parsed single-event `.ics`
|
||||
* file ([ImportSource.File]) or an external `ACTION_INSERT` intent (another
|
||||
* app/widget creating an event, e.g. Google Maps' "add to calendar";
|
||||
* [ImportSource.Insert]; #30, #49). [EventForm.calendarId] is null so the
|
||||
* calendar still resolves to the last-used/first-writable one.
|
||||
*
|
||||
* Reminders are handled per [source], because the two paths mean different
|
||||
* things by "no reminders":
|
||||
* - [ImportSource.Insert] carries no reminder semantics, so the settings
|
||||
* default is applied automatically like an in-app new event (a form that
|
||||
* somehow did carry reminders keeps them, frozen).
|
||||
* - [ImportSource.File] owns its reminders, so they're frozen as-is; if a
|
||||
* settings default is configured and differs, [importReminderPrompt] offers
|
||||
* to swap it in rather than silently deciding for the user.
|
||||
*
|
||||
* No-op when a form is already open, so the prefill survives configuration
|
||||
* changes.
|
||||
*/
|
||||
fun openImported(form: EventForm) {
|
||||
fun openImported(form: EventForm, source: ImportSource) {
|
||||
if (_form.value != null || _editTarget.value != null) return
|
||||
_remindersTouched.value = true
|
||||
_revealed.value = form.populatedFields()
|
||||
_form.value = form
|
||||
when (source) {
|
||||
ImportSource.Insert ->
|
||||
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
|
||||
else applyDefaultReminder()
|
||||
|
||||
ImportSource.File -> {
|
||||
// Respect the file's own reminders; never silently overwrite them.
|
||||
_remindersTouched.value = true
|
||||
maybePromptImportedReminderDefault(form)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,26 +352,12 @@ class EventEditViewModel @Inject constructor(
|
||||
private fun applyDefaultReminder(calendarId: Long? = null) {
|
||||
if (_editTarget.value != null || _remindersTouched.value) return
|
||||
viewModelScope.launch {
|
||||
val defaults = combine(
|
||||
settingsPrefs.defaultReminderMinutes,
|
||||
settingsPrefs.defaultAllDayReminderMinutes,
|
||||
settingsPrefs.perCalendarReminderOverride,
|
||||
settingsPrefs.perCalendarAllDayReminderOverride,
|
||||
) { timed, allDay, timedOv, allDayOv ->
|
||||
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||
}.first()
|
||||
val defaults = reminderDefaults()
|
||||
val targetId = calendarId ?: resolvedCalendarId.first()
|
||||
// Re-check after suspending: bail if the form closed or the user edited.
|
||||
val form = _form.value ?: return@launch
|
||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||
val reminders = resolveDefaultReminder(
|
||||
timedGlobal = defaults.timed,
|
||||
allDayGlobal = defaults.allDay,
|
||||
timedOverrides = defaults.timedOverrides,
|
||||
allDayOverrides = defaults.allDayOverrides,
|
||||
calendarId = targetId,
|
||||
isAllDay = form.isAllDay,
|
||||
)
|
||||
val reminders = defaults.resolveFor(targetId, form.isAllDay)
|
||||
_form.value = form.copy(reminders = reminders)
|
||||
// Surface the section so an auto-applied default is visible and
|
||||
// removable, even when Reminders isn't a default-shown field.
|
||||
@@ -295,10 +367,60 @@ class EventEditViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot the four settings-default reminder flows into one value. */
|
||||
private suspend fun reminderDefaults(): ReminderDefaults = combine(
|
||||
settingsPrefs.defaultReminderMinutes,
|
||||
settingsPrefs.defaultAllDayReminderMinutes,
|
||||
settingsPrefs.perCalendarReminderOverride,
|
||||
settingsPrefs.perCalendarAllDayReminderOverride,
|
||||
) { timed, allDay, timedOv, allDayOv ->
|
||||
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||
}.first()
|
||||
|
||||
/**
|
||||
* A `.ics` import respects the file's reminders, but an event opened from a
|
||||
* file often has none while the user still expects their configured default.
|
||||
* Rather than silently deciding, raise a one-time offer to swap in the
|
||||
* settings default — but only when there's a real choice: a default is
|
||||
* configured and it isn't already exactly what the file carried.
|
||||
*/
|
||||
private fun maybePromptImportedReminderDefault(form: EventForm) {
|
||||
viewModelScope.launch {
|
||||
val targetId = resolvedCalendarId.first()
|
||||
val default = reminderDefaults().resolveFor(targetId, form.isAllDay)
|
||||
// Bail if the form closed or became an edit while we resolved.
|
||||
val current = _form.value ?: return@launch
|
||||
if (_editTarget.value != null) return@launch
|
||||
if (default.isEmpty() || default == current.reminders) return@launch
|
||||
_importReminderPrompt.value = ImportReminderPrompt(
|
||||
currentReminderCount = current.reminders.size,
|
||||
defaultReminders = default,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Accept the import prompt: replace the file's reminders with the default. */
|
||||
fun applyImportedReminderDefault() {
|
||||
val prompt = _importReminderPrompt.value ?: return
|
||||
_importReminderPrompt.value = null
|
||||
// Already frozen as touched by openImported; this just swaps the values.
|
||||
update { it.copy(reminders = prompt.defaultReminders) }
|
||||
_revealed.value = _revealed.value + EventFormField.Reminders
|
||||
}
|
||||
|
||||
/** Decline the import prompt: keep the file's own reminders untouched. */
|
||||
fun dismissImportedReminderPrompt() {
|
||||
_importReminderPrompt.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
||||
* tapped occurrence's own times, like on the detail screen. No-op while a
|
||||
* form is open, so user edits survive configuration changes.
|
||||
* tapped occurrence's own times, like on the detail screen. An external
|
||||
* "edit this event" (`ACTION_EDIT`) that names no occurrence passes
|
||||
* [NO_OCCURRENCE_TIME]; the row's own DTSTART/DTEND is used then, so the
|
||||
* form loads the event's real times instead of the epoch (mirrors the
|
||||
* detail screen's #48 fallback). No-op while a form is open, so user edits
|
||||
* survive configuration changes.
|
||||
*/
|
||||
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
|
||||
if (_form.value != null || _editTarget.value != null) return
|
||||
@@ -312,8 +434,12 @@ class EventEditViewModel @Inject constructor(
|
||||
return@launch
|
||||
}
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone)
|
||||
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone)
|
||||
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
||||
?: detail.instance.start.toEpochMilliseconds()
|
||||
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
||||
?: detail.instance.end.toEpochMilliseconds()
|
||||
val snapshot = detail.toEditSnapshot(begin, end, zone)
|
||||
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone)
|
||||
// Sections holding data must show even when not in the defaults.
|
||||
_revealed.value = snapshot.form.populatedFields()
|
||||
_form.value = snapshot.form
|
||||
@@ -329,6 +455,7 @@ class EventEditViewModel @Inject constructor(
|
||||
_editTarget.value = null
|
||||
_loadFailed.value = false
|
||||
_remindersTouched.value = false
|
||||
_importReminderPrompt.value = null
|
||||
}
|
||||
|
||||
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||
@@ -336,16 +463,36 @@ class EventEditViewModel @Inject constructor(
|
||||
_revealed.value = _revealed.value + field
|
||||
}
|
||||
|
||||
fun setTitle(value: String) = update { it.copy(title = value) }
|
||||
// The title field wraps (multi-line) so long titles stay visible (#33), but
|
||||
// a title is one logical line: drop any newline the IME's Enter key or a
|
||||
// paste would introduce, so it never reaches the provider's TITLE column.
|
||||
fun setTitle(value: String) =
|
||||
update { it.copy(title = value.replace("\n", "").replace("\r", "")) }
|
||||
fun setLocation(value: String) = update { it.copy(location = value) }
|
||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||
fun setAllDay(value: Boolean) {
|
||||
update { it.copy(isAllDay = value) }
|
||||
// Going all-day drops any pinned zone: the times become bare dates that
|
||||
// the provider stores UTC-anchored, so a zone could only misrepresent
|
||||
// them. Coming back out of all-day leaves the event on the device zone,
|
||||
// which is the same thing a fresh event gets.
|
||||
update { it.copy(isAllDay = value, timezone = if (value) null else it.timezone) }
|
||||
// The default reminder differs for all-day vs timed; re-apply the
|
||||
// type-appropriate default unless the user has hand-edited it (guarded).
|
||||
applyDefaultReminder()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin the event to [zoneId], or pass null to follow the device. The zone
|
||||
* rides along as a recent so the picker can offer it without a search next
|
||||
* time — only real picks are remembered, not the device fallback.
|
||||
*/
|
||||
fun setTimezone(zoneId: String?) {
|
||||
update { it.copy(timezone = zoneId) }
|
||||
if (zoneId != null) {
|
||||
viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switching calendars drops any chosen colour: a palette key is
|
||||
* account-scoped, and a raw colour may be invalid on the new calendar.
|
||||
@@ -435,10 +582,15 @@ class EventEditViewModel @Inject constructor(
|
||||
_saveState.value = SaveUiState.Saved
|
||||
return
|
||||
}
|
||||
// Changing the calendar moves the event (copy+delete — CALENDAR_ID can't
|
||||
// be updated in place) and is inherently whole-series: an occurrence
|
||||
// can't live in a different calendar than its series, so a move skips the
|
||||
// scope dialog even for a recurring event.
|
||||
val movingCalendar = target != null && form.calendarId != target.original.calendarId
|
||||
// Managed events are a yearly series whose editable fields (reminders,
|
||||
// notes, location) live on the series row — never offer the scope dialog,
|
||||
// which would split the series into an exception the sync then reverts.
|
||||
if (target != null && target.original.rrule != null && !current.isManaged) {
|
||||
if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar) {
|
||||
_saveState.value = SaveUiState.AwaitingScope
|
||||
return
|
||||
}
|
||||
@@ -493,6 +645,17 @@ class EventEditViewModel @Inject constructor(
|
||||
if (target == null) {
|
||||
repository.createEvent(form)
|
||||
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||
} else if (form.calendarId != target.original.calendarId) {
|
||||
// Move to the picked calendar (copy+delete), carrying any
|
||||
// field edits made in the same save. The target becomes the
|
||||
// last-used calendar, like a create does.
|
||||
repository.moveEvent(
|
||||
eventId = target.eventId,
|
||||
targetCalendarId = requireNotNull(form.calendarId),
|
||||
original = target.original,
|
||||
updated = form,
|
||||
)
|
||||
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||
} else {
|
||||
when (scope) {
|
||||
RecurringWriteScope.ThisEvent ->
|
||||
|
||||
@@ -21,8 +21,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.sourceAppName
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
/**
|
||||
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
|
||||
@@ -61,7 +62,15 @@ private fun FilterList(
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
groups.forEach { group ->
|
||||
Text(
|
||||
text = group.account,
|
||||
text = if (group.ambiguous) {
|
||||
stringResource(
|
||||
R.string.calendars_account_from_source,
|
||||
group.account,
|
||||
sourceAppName(group.accountType),
|
||||
)
|
||||
} else {
|
||||
group.account
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
||||
|
||||
@@ -13,9 +13,17 @@ sealed interface FilterUiState {
|
||||
data class Success(val groups: List<AccountGroup>) : FilterUiState
|
||||
}
|
||||
|
||||
/** Calendars grouped under the account that owns them (Nextcloud / Local / …). */
|
||||
/**
|
||||
* Calendars grouped under the account that owns them (Nextcloud / Local / …).
|
||||
*
|
||||
* [accountType] and [ambiguous] carry what the header needs to tell two
|
||||
* same-named accounts from different apps apart (#77); the label itself is
|
||||
* built in the UI layer, which is where the source app's name can be looked up.
|
||||
*/
|
||||
data class AccountGroup(
|
||||
val account: String,
|
||||
val accountType: String,
|
||||
val ambiguous: Boolean,
|
||||
val calendars: List<CalendarRow>,
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user