Compare commits
255 Commits
v2.13.0
...
ca2069da0d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | |||
| bb7954d026 | |||
| d9f4239729 | |||
| cf3b897305 | |||
| 87a78ca924 | |||
| 13debac340 | |||
| 76846420a2 | |||
| 65bd6c4254 | |||
| 1a3e4f501f | |||
| 974d65f619 | |||
|
|
721ca0d7e0 | ||
|
|
2556eba84b | ||
|
|
fc1ff97d22 | ||
|
|
b7f3d421ef | ||
|
|
2722b3427a | ||
|
|
e28c293d27 | ||
|
|
5cb2a836ab | ||
|
|
b5978e7a61 | ||
| c4a048adee | |||
|
|
2746ca5784 | ||
| fc9bd33992 |
@@ -29,6 +29,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
# Full history so the base..HEAD diff below has a merge-base.
|
# Full history so the base..HEAD diff below has a merge-base.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
# Cheap, always-on guard: the release build must stay reproducible for the
|
# Cheap, always-on guard: the release build must stay reproducible for the
|
||||||
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
|
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
|
||||||
@@ -71,9 +72,14 @@ jobs:
|
|||||||
distribution: 'zulu'
|
distribution: 'zulu'
|
||||||
java-version: '17'
|
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
|
- name: Setup Android SDK
|
||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
uses: android-actions/setup-android@v3
|
uses: https://github.com/android-actions/setup-android@v3
|
||||||
with:
|
with:
|
||||||
# Default ("tools platform-tools") drags in the Android Emulator
|
# Default ("tools platform-tools") drags in the Android Emulator
|
||||||
# (~300 MB) which the build never uses.
|
# (~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
|
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
||||||
# only touch values-*/strings.xml) get quick feedback without the full Android
|
# only touch values-*/strings.xml) get quick feedback without the full Android
|
||||||
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
# 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:
|
on:
|
||||||
pull_request:
|
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:
|
concurrency:
|
||||||
group: translations-${{ github.ref }}
|
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
|
# 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,
|
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
|
||||||
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
|
# 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
|
# it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and mirrors
|
||||||
# itself — the tag is an output of the pipeline, not its trigger. Ordinary
|
# that release to Codeberg with the signed APK + a SHA-256 checksum as a
|
||||||
# merges (no version bump) fall through `detect` and do nothing.
|
# 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
|
# 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,
|
# 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
|
# whether this push actually cuts a new release (no tag for it yet). Keeps the
|
||||||
# heavy job from running on every merge to main.
|
# heavy job from running on every merge to main.
|
||||||
detect:
|
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
|
runs-on: docker
|
||||||
outputs:
|
outputs:
|
||||||
is_release: ${{ steps.v.outputs.is_release }}
|
is_release: ${{ steps.v.outputs.is_release }}
|
||||||
@@ -33,12 +43,22 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Resolve version and whether it is a new release
|
- name: Resolve version and whether it is a new release
|
||||||
id: v
|
id: v
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
# Tags are read from Codeberg, which is canonical — deliberately NOT
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
# 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: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||||
@@ -56,15 +76,28 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
# A tag for this version already existing means the release shipped on
|
# A tag for this version already existing means the release shipped on
|
||||||
# an earlier push; do nothing. Absent => this merge cuts the release.
|
# 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")
|
# Anything other than a clean 200/404 is treated as fatal rather than
|
||||||
if [ "$STATUS" = "200" ]; then
|
# as "no tag". A Codeberg outage or a network blip would otherwise
|
||||||
echo "Tag v$VERSION already exists — nothing to release."
|
# 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"
|
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||||
else
|
;;
|
||||||
echo "No tag for v$VERSION yet — cutting the release."
|
404)
|
||||||
|
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
|
||||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
;;
|
||||||
|
*)
|
||||||
|
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.
|
# 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
|
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
||||||
@@ -82,6 +115,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
@@ -346,3 +381,99 @@ jobs:
|
|||||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||||
-F "attachment=@/tmp/$ASSET" \
|
-F "attachment=@/tmp/$ASSET" \
|
||||||
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
|
"$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
|
- name: Run Renovate
|
||||||
run: renovate
|
run: renovate
|
||||||
env:
|
env:
|
||||||
# Self-hosted Gitea, not github.com.
|
# Renovate targets Codeberg (canonical) while still RUNNING on the
|
||||||
RENOVATE_PLATFORM: gitea
|
# Gitea runner. Moving the job to Codeberg would put a repo-write
|
||||||
RENOVATE_ENDPOINT: https://gitea.jeanlucmakiola.de/api/v1
|
# token on the contributor-facing runner, which is exactly what the
|
||||||
# Bot-account token (Gitea secret). Needs repo read/write + PR scope.
|
# .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 }}
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
# Scope to this repo only — no org-wide autodiscovery.
|
# Scope to this repo only — no org-wide autodiscovery.
|
||||||
RENOVATE_AUTODISCOVER: 'false'
|
RENOVATE_AUTODISCOVER: 'false'
|
||||||
RENOVATE_REPOSITORIES: '["makiolaj/calendula"]'
|
RENOVATE_REPOSITORIES: '["jlmakiola/calendula"]'
|
||||||
# Commits/PRs authored as the bot, not a real maintainer.
|
# 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>'
|
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
|
LOG_LEVEL: info
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -55,3 +55,6 @@ Thumbs.db
|
|||||||
|
|
||||||
# KSP
|
# KSP
|
||||||
.ksp/
|
.ksp/
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
/CLAUDE.md
|
||||||
|
|||||||
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,381 @@ 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/),
|
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).
|
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 → Agenda → 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]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 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
|
||||||
|
- Create events from other apps and widgets. Calendula now registers the
|
||||||
|
standard "insert event" intent (`ACTION_INSERT` on the calendar events type),
|
||||||
|
so other apps and home-screen widgets — such as the Todo Agenda widget — can
|
||||||
|
hand off to Calendula to create a new event. It opens the new-event form
|
||||||
|
prefilled with whatever they passed (title, start/end time, all-day, location,
|
||||||
|
description, recurrence), and picks your last-used or first writable calendar.
|
||||||
|
Thanks to @dschuermann for the suggestion ([#30]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Some recurring events could not be opened. Events in a series that started
|
||||||
|
before 1970 — for example yearly birthdays or anniversaries synced over CalDAV
|
||||||
|
— showed "Something went wrong" instead of opening, because their stored start
|
||||||
|
time is a negative value that was wrongly treated as invalid. They now open
|
||||||
|
normally and appear in search again. A related case (an event whose stored end
|
||||||
|
precedes its start) is now kept and openable instead of failing the same way.
|
||||||
|
Thanks to @dschuermann for the report ([#34]).
|
||||||
|
- The time picker now follows your 24-hour setting. With Calendula set to
|
||||||
|
24-hour time, the clock dial for choosing an event's start and end time still
|
||||||
|
showed AM/PM instead of a 24-hour dial; it now matches your setting (and the
|
||||||
|
same fix applies to the all-day reminder time in Settings). Thanks to
|
||||||
|
@abrossimow for the report ([#27]).
|
||||||
|
|
||||||
## [2.13.0] — 2026-07-03
|
## [2.13.0] — 2026-07-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -808,5 +1183,43 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
||||||
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
|
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
|
||||||
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
||||||
|
[#22]: https://codeberg.org/jlmakiola/calendula/issues/22
|
||||||
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
|
[#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
|
[#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
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
|
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
|
||||||
|
|
||||||
<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/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/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">
|
<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 +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 21300
|
versionCode = 21600
|
||||||
versionName = "2.13.0"
|
versionName = "2.16.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -113,10 +113,13 @@ android {
|
|||||||
lint {
|
lint {
|
||||||
// Community translations are expected to be partial — a missing string
|
// 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
|
// 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,
|
// check_translations.py guards the same invariants with clearer,
|
||||||
// translator-facing messages.
|
// translator-facing messages.
|
||||||
informational += "MissingTranslation"
|
informational += listOf("MissingTranslation", "MissingQuantity")
|
||||||
}
|
}
|
||||||
|
|
||||||
testOptions {
|
testOptions {
|
||||||
@@ -161,6 +164,12 @@ dependencies {
|
|||||||
implementation(libs.androidx.glance.material3)
|
implementation(libs.androidx.glance.material3)
|
||||||
|
|
||||||
implementation(libs.kotlinx.datetime)
|
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)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
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
|
# SessionWorker never ran, and widgets were stuck on their loading layout
|
||||||
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
|
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
|
||||||
-keep class * extends androidx.work.InputMerger { <init>(...); }
|
-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 androidx.test.rule.GrantPermissionRule
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -31,7 +32,12 @@ class CalendarRepositorySmokeTest {
|
|||||||
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||||
produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") },
|
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
|
@Test
|
||||||
|
|||||||
@@ -33,6 +33,14 @@
|
|||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
<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
|
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||||
returns null and the calendar manager's per-account "manage" button can't
|
returns null and the calendar manager's per-account "manage" button can't
|
||||||
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
||||||
@@ -63,10 +71,9 @@
|
|||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<!-- The MAIN/LAUNCHER entry (the launcher icon + its label) lives on
|
||||||
<action android:name="android.intent.action.MAIN" />
|
the two <activity-alias> below, so the app name can be switched at
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
runtime (issue #44). MainActivity keeps every other filter. -->
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- Be selectable as the system calendar app. Android has no API for
|
<!-- Be selectable as the system calendar app. Android has no API for
|
||||||
an app to make itself the default, so registering the filters
|
an app to make itself the default, so registering the filters
|
||||||
@@ -97,26 +104,156 @@
|
|||||||
<data android:mimeType="time/epoch" />
|
<data android:mimeType="time/epoch" />
|
||||||
</intent-filter>
|
</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>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="content" android:mimeType="text/calendar" />
|
<data android:scheme="content" />
|
||||||
<data android:scheme="file" android:mimeType="text/calendar" />
|
<data android:scheme="file" />
|
||||||
|
<data android:mimeType="text/calendar" />
|
||||||
|
<data android:mimeType="text/x-vcalendar" />
|
||||||
|
<data android:mimeType="application/ics" />
|
||||||
</intent-filter>
|
</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>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:mimeType="text/calendar" />
|
<data android:mimeType="text/calendar" />
|
||||||
|
<data android:mimeType="text/x-vcalendar" />
|
||||||
|
<data android:mimeType="application/ics" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
<!-- 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
|
||||||
|
event's fields as CalendarContract extras
|
||||||
|
(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>
|
||||||
|
|
||||||
|
<!-- 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
|
<meta-data
|
||||||
android:name="android.app.shortcuts"
|
android:name="android.app.shortcuts"
|
||||||
android:resource="@xml/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
|
<!-- Standalone surface for a captured crash report. MainActivity routes
|
||||||
here on a startup crash-loop, so it stays clear of the app's Hilt
|
here on a startup crash-loop, so it stays clear of the app's Hilt
|
||||||
@@ -141,17 +278,22 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
<!-- Reminder delivery is the app's own (#75): it plans the alarms from
|
||||||
no notification itself — a calendar app must (v1.4, Etar model).
|
Instances + Reminders instead of waiting for the provider's
|
||||||
Exported: the broadcast arrives from the provider's process. -->
|
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
|
<receiver
|
||||||
android:name=".data.reminders.EventReminderReceiver"
|
android:name=".data.reminders.ReminderScheduleReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.EVENT_REMINDER" />
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
<data
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
android:host="com.android.calendar"
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
android:scheme="content" />
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import android.app.Application
|
|||||||
import dagger.hilt.android.EntryPointAccessors
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
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.backup.BackupWorker
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -24,10 +28,57 @@ class CalendulaApp : Application() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
// Install first thing so startup crashes are captured too (privacy-
|
// Install first thing so startup crashes are captured too (privacy-
|
||||||
// respecting, on-device; the user submits the report by hand).
|
// respecting, on-device; the user submits the report by hand). The
|
||||||
CrashReporter.install(this)
|
// 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()
|
reconcileAutoBackup()
|
||||||
reconcileSpecialDates()
|
reconcileSpecialDates()
|
||||||
|
reconcileCalendarVisibility()
|
||||||
|
startReminderDelivery()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring reminder delivery up with the process (#75). The app plans and arms
|
||||||
|
* its own reminder alarms now, so launch is one of the moments that has to
|
||||||
|
* re-check them: a scan re-arms whatever the system dropped, posts anything
|
||||||
|
* a missed alarm still owes, and starts watching the provider so an edit
|
||||||
|
* re-plans without waiting for the next pass. The daily worker is the
|
||||||
|
* backstop for a device that drops the alarm with no reboot to announce it.
|
||||||
|
*/
|
||||||
|
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 the app hasn't been allowed to write into
|
||||||
|
* the system's `Calendars.VISIBLE` yet — including the retired app-local
|
||||||
|
* "disabled calendars" set the upgrade inherits (#75). A no-op on a fresh
|
||||||
|
* install and in the steady state; a launch without the calendar permission
|
||||||
|
* leaves the set pending, and `RootScreen` runs it again once the app comes
|
||||||
|
* up holding it — whichever way it was granted.
|
||||||
|
*/
|
||||||
|
private fun reconcileCalendarVisibility() {
|
||||||
|
val deps = EntryPointAccessors.fromApplication(
|
||||||
|
this, CalendarVisibilityReconciler.Deps::class.java,
|
||||||
|
)
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||||
|
deps.calendarVisibilityReconciler().run()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.provider.CalendarContract
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
@@ -22,26 +23,31 @@ import androidx.core.net.toUri
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
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.RootScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
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.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.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.domain.FontRole
|
||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
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.CalendulaTheme
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -60,6 +66,16 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// by CalendarHost's import flow.
|
// by CalendarHost's import flow.
|
||||||
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
||||||
|
|
||||||
|
// A prefilled new-event form from an external ACTION_INSERT launch (another
|
||||||
|
// app/widget asking us to create an event, issue #30). Consumed once by
|
||||||
|
// CalendarHost, which opens it in the create form for review.
|
||||||
|
private var 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
|
// 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
|
// over the calendar on the next launch (the single-crash path). A startup
|
||||||
// crash-loop is handled out of band, before setContent — see below.
|
// crash-loop is handled out of band, before setContent — see below.
|
||||||
@@ -81,9 +97,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
requestedDetailKey = intent.detailKeyOrNull()
|
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||||
requestedNav = intent.navRequestOrNull()
|
requestedNav = intent.navRequestOrNull()
|
||||||
requestedImportUri = intent.importUriOrNull()
|
requestedImportUri = intent.importUriOrNull()
|
||||||
|
requestedInsertForm = intent.insertFormOrNull()
|
||||||
|
requestedEditKey = intent.editEventKeyOrNull()
|
||||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
setContent {
|
setContent {
|
||||||
// One activity-scoped SettingsViewModel drives both the theme here
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
@@ -123,6 +141,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalUse24HourFormat provides use24Hour,
|
LocalUse24HourFormat provides use24Hour,
|
||||||
LocalShowHourLines provides settings.showHourLines,
|
LocalShowHourLines provides settings.showHourLines,
|
||||||
|
LocalSoftenColors provides settings.softenColors,
|
||||||
) {
|
) {
|
||||||
RootScreen(
|
RootScreen(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -132,6 +151,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
onWidgetNavConsumed = { requestedNav = null },
|
onWidgetNavConsumed = { requestedNav = null },
|
||||||
requestedImportUri = requestedImportUri,
|
requestedImportUri = requestedImportUri,
|
||||||
onImportConsumed = { requestedImportUri = null },
|
onImportConsumed = { requestedImportUri = null },
|
||||||
|
requestedInsertForm = requestedInsertForm,
|
||||||
|
onInsertConsumed = { requestedInsertForm = null },
|
||||||
|
requestedEditKey = requestedEditKey,
|
||||||
|
onEditKeyConsumed = { requestedEditKey = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// A persistent corner marker so a debug build is never
|
// A persistent corner marker so a debug build is never
|
||||||
@@ -166,9 +189,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
||||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||||
|
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
||||||
|
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -188,6 +213,39 @@ class MainActivity : AppCompatActivity() {
|
|||||||
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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? {
|
||||||
|
// 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),
|
||||||
|
isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
|
||||||
|
title = getStringExtra(CalendarContract.Events.TITLE),
|
||||||
|
description = getStringExtra(CalendarContract.Events.DESCRIPTION),
|
||||||
|
location = getStringExtra(CalendarContract.Events.EVENT_LOCATION),
|
||||||
|
rrule = getStringExtra(CalendarContract.Events.RRULE),
|
||||||
|
zone = TimeZone.currentSystemDefault(),
|
||||||
|
now = Clock.System.now(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Long extra's value, or null when the extra is absent. */
|
||||||
|
private fun Intent.longExtraOrNull(key: String): Long? =
|
||||||
|
if (hasExtra(key)) getLongExtra(key, 0L) else null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
|
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
|
||||||
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
|
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
|
||||||
@@ -255,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 {
|
companion object {
|
||||||
// The calendar provider's authority/host. A date tap arrives as
|
// The calendar provider's authority/host. A date tap arrives as
|
||||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
// 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
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import java.time.Instant
|
import de.jeanlucmakiola.calendula.domain.reminders.allDayLeadDays
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import java.time.temporal.ChronoUnit
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translates an all-day reminder between the **semantic** lead time the UI
|
* Translates an all-day reminder between the **semantic** lead time the UI
|
||||||
@@ -73,19 +72,14 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Recover the semantic whole-day lead time from a raw all-day reminder
|
* 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
|
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
|
||||||
* returns the right day count regardless of which [timeOfDayMinutes] wrote the
|
* detail screen. Delegates to [allDayLeadDays] so what is displayed is the day
|
||||||
* row — including pre-feature rows (raw multiples of 1440, fired at UTC midnight)
|
* the reminder actually fires on; see there for how an encoded row is told from
|
||||||
* and rows written under a different timezone. A negative [rawMinutes] (fire
|
* a plain one written by another calendar app.
|
||||||
* after DTSTART) folds to day 0.
|
|
||||||
*/
|
*/
|
||||||
internal fun fromProviderAllDayMinutes(
|
internal fun fromProviderAllDayMinutes(
|
||||||
rawMinutes: Int,
|
rawMinutes: Int,
|
||||||
startDate: LocalDate,
|
startDate: LocalDate,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
): Int {
|
timeOfDayMinutes: Int,
|
||||||
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
): Int = allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
|
||||||
val fireLocalDate = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE)
|
|
||||||
.atZone(zone).toLocalDate()
|
|
||||||
return ChronoUnit.DAYS.between(fireLocalDate, startDate).toInt() * MINUTES_PER_DAY
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
@@ -16,10 +18,13 @@ import android.util.Log
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
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.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
@@ -49,7 +54,12 @@ import javax.inject.Singleton
|
|||||||
interface CalendarDataSource {
|
interface CalendarDataSource {
|
||||||
fun calendars(): List<CalendarSource>
|
fun calendars(): List<CalendarSource>
|
||||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
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
|
* 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
|
* The event-colour palette the calendar's account publishes
|
||||||
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
|
* (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped,
|
||||||
* account exposes no palette (most local calendars, some CalDAV) — the
|
* thinned to visually distinct swatches when oversized (CalDAV adapters
|
||||||
* signal that a custom colour can only be written as a raw `EVENT_COLOR`,
|
* publish all ~147 CSS3 names, #22) and hue-sorted; see [curatedForPicker].
|
||||||
* which a synced calendar may drop on its next sync.
|
* 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>
|
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every master/one-off event of the writable local calendars, mapped for a
|
* Every master/one-off event of the writable local calendars, mapped for a
|
||||||
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
* 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
|
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||||
@@ -101,6 +115,32 @@ interface CalendarDataSource {
|
|||||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||||
fun deleteCalendar(id: Long)
|
fun deleteCalendar(id: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show or hide the calendar device-wide by writing `Calendars.VISIBLE` — the
|
||||||
|
* app's one visibility model (#75). `VISIBLE` also gates the provider's own
|
||||||
|
* reminder scheduling, so switching a calendar off here is what actually
|
||||||
|
* stops its notifications; switching it on is what brings them back.
|
||||||
|
* Writable by a plain app (one of the three columns the platform documents
|
||||||
|
* as such) and device-local — no sync adapter pushes it anywhere.
|
||||||
|
*/
|
||||||
|
fun setCalendarVisible(id: Long, visible: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether one calendar is currently switched on at system level, without
|
||||||
|
* reading every row — for the reminder gate, which sees a calendar id and
|
||||||
|
* nothing else. Null when the answer can't be had: no row (the calendar was
|
||||||
|
* deleted) 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]
|
* Create a local calendar tagged as the special-dates mirror for [type]
|
||||||
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
|
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
|
||||||
@@ -172,6 +212,25 @@ interface CalendarDataSource {
|
|||||||
allDayReminderTimeMinutes: Int,
|
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
|
* Change a single occurrence of a recurring event by inserting a
|
||||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||||
@@ -345,6 +404,40 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
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` short-circuits to a raw database
|
||||||
|
* update unless the selection is `_id=…`, skipping the dirty marking *and*
|
||||||
|
* the `checkNextAlarm()` reschedule — i.e. an `_id IN (…)` batch would write
|
||||||
|
* the flag but never re-arm the reminder alarms this write exists to
|
||||||
|
* trigger. The sync-adapter URI is avoided so the write also applies to
|
||||||
|
* synced calendars, which is where the bug bites.
|
||||||
|
*/
|
||||||
|
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 {
|
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
@@ -488,7 +581,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
return resolver.query(
|
return resolver.query(
|
||||||
uri,
|
uri,
|
||||||
InstanceProjection.COLUMNS,
|
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",
|
CalendarContract.Instances.BEGIN + " ASC",
|
||||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||||
}
|
}
|
||||||
@@ -576,7 +675,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun eventDetail(eventId: Long): EventDetail? {
|
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? {
|
||||||
val attendees = queryAttendees(eventId)
|
val attendees = queryAttendees(eventId)
|
||||||
val reminders = queryReminders(eventId)
|
val reminders = queryReminders(eventId)
|
||||||
return resolver.query(
|
return resolver.query(
|
||||||
@@ -585,7 +684,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
null, null, null,
|
null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
if (!c.moveToFirst()) null
|
if (!c.moveToFirst()) null
|
||||||
else CursorColumnReader(c).toEventDetailCore(attendees, reminders)
|
else CursorColumnReader(c).toEventDetailCore(
|
||||||
|
attendees, reminders, allDayReminderTimeMinutes,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,16 +708,23 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
||||||
}
|
}
|
||||||
?.filter { it.key.isNotEmpty() }
|
?.filter { it.key.isNotEmpty() }
|
||||||
?.sortedBy { it.key }
|
?.curatedForPicker()
|
||||||
?: emptyList()
|
?: 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
|
// 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.
|
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
|
||||||
val names = calendars()
|
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 }
|
.associate { it.id to it.displayName }
|
||||||
if (names.isEmpty()) return emptyList()
|
if (names.isEmpty()) return emptyList()
|
||||||
|
|
||||||
@@ -865,6 +973,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(
|
override fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
@@ -959,6 +1251,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
CalendarContract.Events.DURATION,
|
CalendarContract.Events.DURATION,
|
||||||
CalendarContract.Events.ALL_DAY,
|
CalendarContract.Events.ALL_DAY,
|
||||||
|
CalendarContract.Events._SYNC_ID,
|
||||||
|
CalendarContract.Events.EXDATE,
|
||||||
),
|
),
|
||||||
null, null, null,
|
null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
@@ -969,6 +1263,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
timezone = c.getString(2),
|
timezone = c.getString(2),
|
||||||
duration = c.getString(3),
|
duration = c.getString(3),
|
||||||
allDay = c.getInt(4),
|
allDay = c.getInt(4),
|
||||||
|
syncId = c.getString(5),
|
||||||
|
exdate = c.getString(6),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -981,6 +1277,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val timezone: String?,
|
val timezone: String?,
|
||||||
val duration: String?,
|
val duration: String?,
|
||||||
val allDay: Int,
|
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]. */
|
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
||||||
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
||||||
@@ -1171,16 +1470,49 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
// A cancelled exception row hides exactly this occurrence; the sync
|
val row = querySeriesRow(eventId)
|
||||||
// adapter turns it into an EXDATE/cancelled VEVENT upstream.
|
if (row.syncId == null) {
|
||||||
val values = ContentValues().apply {
|
// No _sync_id — a local calendar, or a synced event not pushed yet.
|
||||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, beginMillis)
|
// A cancelled exception can only attach to its parent through
|
||||||
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
// 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(
|
val uri = ContentUris.withAppendedId(
|
||||||
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
|
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
|
||||||
)
|
)
|
||||||
resolver.insert(uri, values)
|
resolver.insert(uri, values.toContentValues())
|
||||||
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
|
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,5 +31,10 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
|||||||
isManaged = isLocal &&
|
isManaged = isLocal &&
|
||||||
getString(CalendarProjection.IDX_MANAGED_MARKER)
|
getString(CalendarProjection.IDX_MANAGED_MARKER)
|
||||||
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
|
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
|
||||||
|
// A provider that leaves the column NULL is treated as syncing — the
|
||||||
|
// harmless default, since this flag only ever holds the one-shot
|
||||||
|
// visibility migration back from switching a calendar on.
|
||||||
|
syncsEvents = isNull(CalendarProjection.IDX_SYNC_EVENTS) ||
|
||||||
|
getInt(CalendarProjection.IDX_SYNC_EVENTS) != 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,11 +38,26 @@ interface CalendarRepository {
|
|||||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||||
suspend fun deleteCalendar(id: Long)
|
suspend fun deleteCalendar(id: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show or hide [ids] device-wide (`Calendars.VISIBLE`), which is also what
|
||||||
|
* turns the provider's reminder scheduling for them on or off — see
|
||||||
|
* [CalendarDataSource.setCalendarVisible]. Each calendar is written on its
|
||||||
|
* own, in order; a failure part-way leaves the earlier writes standing (the
|
||||||
|
* observer reports whatever actually landed).
|
||||||
|
*
|
||||||
|
* Without `WRITE_CALENDAR` the choice is kept app-side instead (see
|
||||||
|
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds]),
|
||||||
|
* where it filters events and reminders just the same until it can be
|
||||||
|
* written.
|
||||||
|
*/
|
||||||
|
suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every event of the writable local calendars, ready to serialise into a
|
* Every event of the writable local calendars, ready to serialise into a
|
||||||
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
* 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
|
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
|
||||||
@@ -60,6 +75,20 @@ interface CalendarRepository {
|
|||||||
*/
|
*/
|
||||||
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
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
|
* Change a single occurrence of a recurring event (exception row with the
|
||||||
* form's values); returns the exception's `Events._ID`.
|
* form's values); returns the exception's `Events._ID`.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
@@ -15,11 +16,17 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -46,52 +53,141 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
extraBufferCapacity = 1,
|
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 {
|
init {
|
||||||
dataSource.registerChangeListener { ticks.tryEmit(Unit) }
|
dataSource.registerChangeListener {
|
||||||
|
generation.incrementAndGet()
|
||||||
|
ticks.tryEmit(Unit)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun calendars(): Flow<List<CalendarSource>> =
|
/**
|
||||||
ticks
|
* Re-query signal for everything filtered by visibility: the provider's own
|
||||||
.onStart { emit(Unit) }
|
* notifications, plus every change to the pending switch-off set (an id
|
||||||
.reQuery { dataSource.calendars() }
|
* leaves it as its `VISIBLE` write lands, which changes what is shown).
|
||||||
.flowOn(io)
|
* [calendarsSnapshot] keeps the two in step.
|
||||||
|
*/
|
||||||
// Instances are filtered by the app-side hidden ∪ disabled calendar sets
|
private fun visibilityTicks(): Flow<Unit> = merge(
|
||||||
// (M3): an event is dropped whenever the user has hidden *or* disabled its
|
ticks.onStart { emit(Unit) },
|
||||||
// calendar. Re-runs when the provider ticks *or* either set changes —
|
// The current value is already covered by the tick above; only later
|
||||||
// toggling a calendar in the filter sheet or the calendar manager updates
|
// changes re-query (the set is deduped, so an unrelated DataStore write
|
||||||
// every view immediately. [calendars] stays unfiltered so those screens can
|
// doesn't).
|
||||||
// list and re-enable hidden/disabled calendars.
|
prefs.pendingDisabledCalendarIds.drop(1).map {},
|
||||||
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,
|
// A switch-off the app hasn't been allowed to write yet is folded into the
|
||||||
prefs.disabledCalendarIds,
|
// flag itself, so every consumer — the Settings switch, the filter sheet,
|
||||||
) { instances, hidden, disabled ->
|
// the form and import pickers, the widgets — reads one visibility and can't
|
||||||
val excluded = hidden + disabled
|
// disagree with what the user just tapped. The reconciler reads the data
|
||||||
if (excluded.isEmpty()) instances
|
// source directly, because it needs the provider's own answer.
|
||||||
else instances.filterNot { it.calendarId in excluded }
|
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
|
// Collapse re-emissions that carry an identical list (see
|
||||||
// list twice — collapse the duplicate so views don't re-render for it.
|
// [instances]).
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.flowOn(io)
|
.flowOn(io)
|
||||||
|
|
||||||
|
// Instances are filtered by the system's per-calendar VISIBLE flag ∪ the
|
||||||
|
// switch-offs still waiting to be written to it ∪ the app-side hidden set:
|
||||||
|
// an event is dropped when the user switched its calendar off in Settings →
|
||||||
|
// Calendars (which also stops the provider scheduling its reminders) *or*
|
||||||
|
// hid it in the filter sheet. Re-runs when the provider ticks — writing
|
||||||
|
// VISIBLE notifies, so switching a calendar updates every view — or when
|
||||||
|
// either set changes. [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 it is
|
||||||
|
// unchanged (e.g. writing the last-used calendar), which would
|
||||||
|
// re-surface an identical list — 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. Every
|
||||||
|
* open view collects [calendars] *and* filters its instances by visibility,
|
||||||
|
* which used to cost one full `Calendars` query each per tick. Reusing a
|
||||||
|
* single read also keeps them consistent: within a tick, what a screen lists
|
||||||
|
* and what its events are filtered against can't come from two snapshots.
|
||||||
|
*
|
||||||
|
* An empty result is never cached — it 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. An id leaves
|
||||||
|
* that set the moment its `VISIBLE` write lands, while the observer that
|
||||||
|
* would invalidate the snapshot is only dispatched through the main looper
|
||||||
|
* afterwards — so a snapshot taken while the id was still pending, read
|
||||||
|
* against the set that no longer holds it, would report the calendar as *on*
|
||||||
|
* again and re-admit exactly the events being hidden.
|
||||||
|
*/
|
||||||
|
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) {
|
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) {
|
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
||||||
if (query.isBlank()) return@withContext emptyList()
|
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)
|
dataSource.searchEvents(query)
|
||||||
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
||||||
}
|
}
|
||||||
@@ -117,7 +213,24 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun deleteCalendar(id: Long) =
|
override suspend fun deleteCalendar(id: Long) =
|
||||||
withContext(io) { dataSource.deleteCalendar(id) }
|
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(
|
override suspend fun importEvents(
|
||||||
targetCalendarId: Long,
|
targetCalendarId: Long,
|
||||||
@@ -155,6 +268,17 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
dataSource.deleteEvent(eventId)
|
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(
|
override suspend fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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 the app's pending "switched off" set (see
|
||||||
|
* [CalendarPrefs.pendingDisabledCalendarIds]) and the system's
|
||||||
|
* `Calendars.VISIBLE` in step — the fold-in of the retired app-local visibility
|
||||||
|
* model (#75), and the standing drain for switch-offs made without
|
||||||
|
* `WRITE_CALENDAR`.
|
||||||
|
*
|
||||||
|
* Runs on every launch, and again whenever the app comes up holding the calendar
|
||||||
|
* permission — a grant made on Android's own app-settings screen never reaches
|
||||||
|
* the permission screen's callback. It is a no-op whenever the pending set is
|
||||||
|
* empty and the notice has been settled, which
|
||||||
|
* is the steady state: each entry is written and dropped individually, so a run
|
||||||
|
* that dies part-way resumes exactly where it stopped and never re-applies a
|
||||||
|
* write the user has since undone by hand.
|
||||||
|
*
|
||||||
|
* The reconciliation only hides (see [calendarVisibilityPlan]). Calendars hidden
|
||||||
|
* at system level stay hidden, and on an *upgraded* install the first run that
|
||||||
|
* sees one arms the one-time notice explaining why Calendula no longer lists
|
||||||
|
* their events. A fresh install never had the old behaviour, so it retires that
|
||||||
|
* notice unshown — every other device ships with something hidden.
|
||||||
|
*/
|
||||||
|
@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) {
|
||||||
|
// Everything, the DataStore reads included, sits inside the guard: this
|
||||||
|
// runs in a bare application-scope coroutine with no exception handler,
|
||||||
|
// so an IOException from a damaged preferences file would otherwise take
|
||||||
|
// the process down on every launch.
|
||||||
|
try {
|
||||||
|
// A fresh install has no retired model behind it — nothing to
|
||||||
|
// migrate, and nothing to explain. Settled ahead of the permission
|
||||||
|
// gate so an update installed before the first grant can't make a
|
||||||
|
// first run look like an upgrade afterwards.
|
||||||
|
if (!isUpgradeInstall()) settleNoticeOnce(pending = false)
|
||||||
|
if (!hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
|
||||||
|
val pending = prefs.pendingDisabledCalendarIds.first()
|
||||||
|
val noticeSettled = prefs.visibilityNoticePending.first() != null
|
||||||
|
// The steady state, and every run after the first: nothing left to
|
||||||
|
// drain and nothing left to decide, so don't pay for the query.
|
||||||
|
if (pending.isEmpty() && noticeSettled) return@withContext
|
||||||
|
val calendars = dataSource.calendars()
|
||||||
|
// An empty read means "couldn't read", not "no calendars": the data
|
||||||
|
// source turns a null cursor — a provider momentarily unavailable —
|
||||||
|
// into an empty list. Both decisions below are one-way, so taking
|
||||||
|
// that reading as the truth would drop the whole pending set without
|
||||||
|
// ever writing VISIBLE = 0 (switching the user's calendars back on,
|
||||||
|
// events and reminders with them) and settle the notice as "nothing
|
||||||
|
// to explain". Leave both 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)
|
||||||
|
// Already off, or gone from the device — nothing to write, so let
|
||||||
|
// those ids leave the pending set with the rest.
|
||||||
|
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]). Dropping each id as it
|
||||||
|
// lands keeps a part-applied run resumable.
|
||||||
|
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, by whichever run can answer it first; the answer is stored
|
||||||
|
* either way, so the notice can't resurface later, when the same state would
|
||||||
|
* no longer be news to the user.
|
||||||
|
*/
|
||||||
|
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 change to behaviour the user has already seen, so a first install has
|
||||||
|
* nothing to announce — and hidden calendars are the *norm* on a fresh
|
||||||
|
* device (a second account's, "Holidays in …", a subscribed calendar), which
|
||||||
|
* would otherwise put a changelog dialog in front of a first-run user.
|
||||||
|
*/
|
||||||
|
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
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
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.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
@@ -22,27 +24,34 @@ private const val TAG = "EventDetailMapper"
|
|||||||
internal fun ColumnReader.toEventDetailCore(
|
internal fun ColumnReader.toEventDetailCore(
|
||||||
attendees: List<Attendee>,
|
attendees: List<Attendee>,
|
||||||
reminders: List<Reminder>,
|
reminders: List<Reminder>,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
): EventDetail? {
|
): EventDetail? {
|
||||||
|
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
||||||
|
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
||||||
|
// negative — only an *absent* DTSTART marks a malformed row worth dropping.
|
||||||
|
// Dropping negatives made every occurrence of such a series un-openable
|
||||||
|
// (the detail loads the ancient series-master DTSTART), see issue #34.
|
||||||
|
if (isNull(EventDetailProjection.IDX_DTSTART)) {
|
||||||
|
Log.w(TAG, "Dropping event with missing dtstart")
|
||||||
|
return null
|
||||||
|
}
|
||||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||||
|
|
||||||
if (begin < 0L) {
|
|
||||||
Log.w(TAG, "Dropping event with negative dtstart=$begin")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recurring events store DURATION instead of DTEND, so the series row's
|
// Recurring events store DURATION instead of DTEND, so the series row's
|
||||||
// DTEND is null. Keep the event (end == begin); callers that opened a
|
// DTEND is null — derive the length from DURATION (as SearchMapper and
|
||||||
// specific occurrence supply the real per-occurrence times from
|
// IcsExportMapper do). Callers that opened a specific occurrence overwrite
|
||||||
// CalendarContract.Instances. Only a present-but-backwards DTEND is malformed.
|
// 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)) {
|
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||||
begin
|
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))
|
||||||
} else {
|
} else {
|
||||||
val rawEnd = getLong(EventDetailProjection.IDX_DTEND)
|
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
||||||
if (rawEnd < begin) {
|
|
||||||
Log.w(TAG, "Dropping event with dtend=$rawEnd < dtstart=$begin")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
rawEnd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
||||||
@@ -81,7 +90,13 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
val displayReminders = if (isAllDay) {
|
val displayReminders = if (isAllDay) {
|
||||||
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
|
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
val zone = ZoneId.systemDefault()
|
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 {
|
} else {
|
||||||
reminders
|
reminders
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import de.jeanlucmakiola.calendula.domain.Availability
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toJavaLocalDateTime
|
import kotlinx.datetime.toJavaLocalDateTime
|
||||||
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
|
import java.time.LocalDateTime as JavaLocalDateTime
|
||||||
|
|
||||||
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
||||||
internal data class EventWriteTimes(
|
internal data class EventWriteTimes(
|
||||||
@@ -20,7 +22,11 @@ internal data class EventWriteTimes(
|
|||||||
/**
|
/**
|
||||||
* All-day events live at UTC midnights with an exclusive DTEND (the
|
* All-day events live at UTC midnights with an exclusive DTEND (the
|
||||||
* CalendarContract convention — a one-day event ends at the next midnight);
|
* 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) {
|
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
|
||||||
EventWriteTimes(
|
EventWriteTimes(
|
||||||
@@ -31,22 +37,56 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
|||||||
timezone = "UTC",
|
timezone = "UTC",
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
val writeZone = writeZone(zone)
|
||||||
EventWriteTimes(
|
EventWriteTimes(
|
||||||
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||||
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||||
timezone = zone.id,
|
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
|
* 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
|
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
||||||
* events, seconds otherwise.
|
* events, seconds otherwise.
|
||||||
*/
|
*/
|
||||||
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) {
|
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String =
|
||||||
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D"
|
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 {
|
} 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):
|
* Time fields travel together (the provider validates them as a unit):
|
||||||
* - unchanged times, all-day flag and rrule → no time columns at all;
|
* - unchanged times, all-day flag and rrule → no time columns at all;
|
||||||
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
||||||
* - recurring result → the *series* DTSTART moves by the same delta the user
|
* - recurring result → the *series* DTSTART moves by the same **wall-clock**
|
||||||
* applied to the displayed occurrence ([seriesDtStartMillis] is the row's
|
* shift the user applied to the displayed occurrence and is re-resolved in the
|
||||||
* current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps
|
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
|
||||||
* past occurrences intact when someone edits a later occurrence's time.
|
* 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(
|
internal fun buildEventUpdateValues(
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
@@ -125,10 +167,14 @@ internal fun buildEventUpdateValues(
|
|||||||
putAll(eventColorColumns(updated.colorKey, updated.color))
|
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 ||
|
val timesChanged = updated.start != original.start ||
|
||||||
updated.end != original.end ||
|
updated.end != original.end ||
|
||||||
updated.isAllDay != original.isAllDay ||
|
updated.isAllDay != original.isAllDay ||
|
||||||
updated.rrule != original.rrule
|
updated.rrule != original.rrule ||
|
||||||
|
updated.timezone != original.timezone
|
||||||
if (!timesChanged) return@buildMap
|
if (!timesChanged) return@buildMap
|
||||||
|
|
||||||
val newTimes = updated.toWriteTimes(zone)
|
val newTimes = updated.toWriteTimes(zone)
|
||||||
@@ -140,8 +186,23 @@ internal fun buildEventUpdateValues(
|
|||||||
put(CalendarContract.Events.RRULE, null)
|
put(CalendarContract.Events.RRULE, null)
|
||||||
put(CalendarContract.Events.DURATION, null)
|
put(CalendarContract.Events.DURATION, null)
|
||||||
} else {
|
} else {
|
||||||
val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis
|
// Move the series anchor by the *wall-clock* shift the user applied to the
|
||||||
put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta)
|
// 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.DTEND, null)
|
||||||
put(CalendarContract.Events.RRULE, updated.rrule)
|
put(CalendarContract.Events.RRULE, updated.rrule)
|
||||||
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
||||||
@@ -182,6 +243,215 @@ internal fun buildOccurrenceExceptionValues(
|
|||||||
putAll(eventColorColumns(form.colorKey, form.color))
|
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
|
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||||
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ internal object CalendarProjection {
|
|||||||
// uses to recognise its own managed calendars, independent of any
|
// uses to recognise its own managed calendars, independent of any
|
||||||
// stored preference id (which a backup restore / data wipe can lose).
|
// stored preference id (which a backup restore / data wipe can lose).
|
||||||
MANAGED_MARKER_COLUMN,
|
MANAGED_MARKER_COLUMN,
|
||||||
|
CalendarContract.Calendars.SYNC_EVENTS,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
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_ACCESS_LEVEL = 6
|
||||||
const val IDX_DESCRIPTION = 7
|
const val IDX_DESCRIPTION = 7
|
||||||
const val IDX_MANAGED_MARKER = 8
|
const val IDX_MANAGED_MARKER = 8
|
||||||
|
const val IDX_SYNC_EVENTS = 9
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object InstanceProjection {
|
internal object InstanceProjection {
|
||||||
@@ -84,6 +86,9 @@ internal object EventDetailProjection {
|
|||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||||
CalendarContract.Events.EVENT_COLOR_KEY,
|
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
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -104,6 +109,7 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_EVENT_TIMEZONE = 15
|
const val IDX_EVENT_TIMEZONE = 15
|
||||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||||
const val IDX_EVENT_COLOR_KEY = 17
|
const val IDX_EVENT_COLOR_KEY = 17
|
||||||
|
const val IDX_DURATION = 18
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,6 +192,86 @@ internal object SearchProjection {
|
|||||||
const val IDX_RDATE = 11
|
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 {
|
internal object AttendeeProjection {
|
||||||
val COLUMNS: Array<String> = arrayOf(
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
|
|
||||||
@@ -10,8 +11,11 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
|||||||
* of DTEND — reconstruct the end the same way the `.ics` export does.
|
* of DTEND — reconstruct the end the same way the `.ics` export does.
|
||||||
*/
|
*/
|
||||||
internal fun ColumnReader.toSearchResult(): EventInstance? {
|
internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||||
|
// A pre-1970 series anchor is a legitimately negative epoch-millis DTSTART
|
||||||
|
// (see EventDetailMapper / issue #34); drop only a genuinely absent one, so
|
||||||
|
// long-running birthdays/anniversaries still surface in search.
|
||||||
|
if (isNull(SearchProjection.IDX_DTSTART)) return null
|
||||||
val dtStart = getLong(SearchProjection.IDX_DTSTART)
|
val dtStart = getLong(SearchProjection.IDX_DTSTART)
|
||||||
if (dtStart < 0L) return null
|
|
||||||
val end = when {
|
val end = when {
|
||||||
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
|
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
|
||||||
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||||
|
|||||||
@@ -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.CalendarDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
|
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.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
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
|
* all-day override so new events keep matching. No-op if the calendar for
|
||||||
* [type] doesn't exist yet.
|
* [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
|
val calendarId = prefs.specialDatesCalendars.first()[type] ?: return
|
||||||
prefs.setCalendarAllDayReminderOverride(calendarId, override)
|
prefs.setCalendarAllDayReminderOverride(calendarId, override)
|
||||||
val minutes = when (override) {
|
val minutes = when (override) {
|
||||||
CalendarReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
ReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
||||||
CalendarReminderOverride.None -> emptyList()
|
ReminderOverride.None -> emptyList()
|
||||||
is CalendarReminderOverride.Minutes -> override.minutes
|
is ReminderOverride.Minutes -> override.minutes
|
||||||
}
|
}
|
||||||
calendars.applyManagedCalendarReminders(
|
calendars.applyManagedCalendarReminders(
|
||||||
calendarId = calendarId,
|
calendarId = calendarId,
|
||||||
@@ -169,7 +169,7 @@ class SpecialDatesSyncEngine @Inject constructor(
|
|||||||
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
|
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
|
||||||
prefs.setCalendarAllDayReminderOverride(
|
prefs.setCalendarAllDayReminderOverride(
|
||||||
id,
|
id,
|
||||||
CalendarReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
ReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return id
|
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.AndroidSpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -46,9 +46,9 @@ abstract class DataBindModule {
|
|||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindReminderAlertStore(
|
abstract fun bindReminderInstanceSource(
|
||||||
impl: AndroidReminderAlertStore,
|
impl: ProviderReminderInstanceSource,
|
||||||
): ReminderAlertStore
|
): ReminderInstanceSource
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.prefs
|
package de.jeanlucmakiola.calendula.data.prefs
|
||||||
|
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.MutablePreferences
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-side preference for "calendars the user has hidden in this app",
|
* App-side calendar preferences. [hiddenCalendarIds] is the drawer's filter
|
||||||
* separate from the system's per-calendar VISIBLE flag.
|
* 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
|
* Persisted as a comma-separated string of Long ids; non-numeric tokens are
|
||||||
* silently dropped (defensive — see CalendarPrefsTest).
|
* silently dropped (defensive — see CalendarPrefsTest).
|
||||||
@@ -22,46 +29,63 @@ class CalendarPrefs @Inject constructor(
|
|||||||
private val store: DataStore<Preferences>,
|
private val store: DataStore<Preferences>,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val hiddenCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
// Both id sets are deduped: the store is shared with SettingsPrefs, so every
|
||||||
prefs[HIDDEN_IDS_KEY].orEmpty()
|
// unrelated write (a settings toggle, the last-used calendar) re-emits an
|
||||||
.split(',')
|
// identical set otherwise — and a change to the pending set now costs a
|
||||||
.mapNotNull { it.trim().toLongOrNull() }
|
// fresh provider read in CalendarRepositoryImpl.
|
||||||
.toSet()
|
val hiddenCalendarIds: Flow<Set<Long>> = store.data
|
||||||
}
|
.map { prefs -> prefs[HIDDEN_IDS_KEY].parseIds() }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
|
||||||
suspend fun setHiddenCalendarIds(ids: Set<Long>) {
|
suspend fun setHiddenCalendarIds(ids: Set<Long>) {
|
||||||
store.edit { prefs ->
|
store.edit { prefs -> prefs.writeIds(HIDDEN_IDS_KEY, ids) }
|
||||||
if (ids.isEmpty()) {
|
|
||||||
prefs.remove(HIDDEN_IDS_KEY)
|
|
||||||
} else {
|
|
||||||
prefs[HIDDEN_IDS_KEY] = ids.sorted().joinToString(",")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendars switched off in Settings → Calendars that the provider does not
|
||||||
|
* know about yet. That switch writes the system's `Calendars.VISIBLE` (#75),
|
||||||
|
* which needs `WRITE_CALENDAR` — a user who granted read-only keeps their
|
||||||
|
* choice here instead, and so does everyone upgrading from the retired
|
||||||
|
* app-local model, whose set is read straight back out of the same key.
|
||||||
|
*
|
||||||
|
* Honoured as a display and reminder filter for as long as it is non-empty,
|
||||||
|
* so an un-flushable switch still does what the user asked. Not a second
|
||||||
|
* visibility model: `CalendarVisibilityReconciler` drains it into the
|
||||||
|
* provider entry by entry the moment the app may write, and nothing ever
|
||||||
|
* 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 id at a time as the reconciler
|
||||||
|
* flushes it, so a run that fails part-way never re-applies what already
|
||||||
|
* landed (and can't undo a switch the user has since flipped by hand).
|
||||||
|
*/
|
||||||
|
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) =
|
||||||
|
editPendingDisabled { it - ids.toSet() }
|
||||||
|
|
||||||
|
private suspend fun editPendingDisabled(transform: (Set<Long>) -> Set<Long>) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs.writeIds(DISABLED_IDS_KEY, transform(prefs[DISABLED_IDS_KEY].parseIds()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-side preference for "calendars the user has disabled in this app" — a
|
* Whether the one-time "visibility follows this device" notice is still
|
||||||
* heavier level than [hiddenCalendarIds]. A disabled calendar is removed from
|
* owed. Null until the reconciler has evaluated it (which needs the calendar
|
||||||
* every surface (drawer filter, event-form picker, import picker) and its
|
* permission), false once it has been shown or was never needed.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
val disabledCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
val visibilityNoticePending: Flow<Boolean?> = store.data.map { prefs ->
|
||||||
prefs[DISABLED_IDS_KEY].orEmpty()
|
prefs[VISIBILITY_NOTICE_KEY]
|
||||||
.split(',')
|
|
||||||
.mapNotNull { it.trim().toLongOrNull() }
|
|
||||||
.toSet()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setDisabledCalendarIds(ids: Set<Long>) {
|
suspend fun setVisibilityNoticePending(pending: Boolean) {
|
||||||
store.edit { prefs ->
|
store.edit { prefs -> prefs[VISIBILITY_NOTICE_KEY] = pending }
|
||||||
if (ids.isEmpty()) {
|
|
||||||
prefs.remove(DISABLED_IDS_KEY)
|
|
||||||
} else {
|
|
||||||
prefs[DISABLED_IDS_KEY] = ids.sorted().joinToString(",")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,6 +103,16 @@ class CalendarPrefs @Inject constructor(
|
|||||||
companion object {
|
companion object {
|
||||||
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
||||||
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_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")
|
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,42 @@
|
|||||||
|
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. One number, and it replaces everything the
|
||||||
|
* provider's `CalendarAlerts.STATE` used to do for us (#75).
|
||||||
|
*
|
||||||
|
* A scan posts the reminders whose moment falls after this watermark and up to
|
||||||
|
* now, then moves it to now. That single rule gives both halves of what the
|
||||||
|
* retired path got from the provider: a scan that runs twice cannot post the
|
||||||
|
* same reminder again, and a scan that runs *late* — after a reboot, an app
|
||||||
|
* update or a doze window swallowed the alarm — still posts everything the
|
||||||
|
* missed alarm would have.
|
||||||
|
*
|
||||||
|
* Unset means "never scanned". It is deliberately not treated as zero: the first
|
||||||
|
* scan after an install or an upgrade would otherwise consider every reminder
|
||||||
|
* since the epoch overdue and bury the user in notifications.
|
||||||
|
*/
|
||||||
|
@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.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
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.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
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.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
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.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.Flow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import java.time.temporal.WeekFields
|
import java.time.temporal.WeekFields
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -76,6 +88,24 @@ fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
|||||||
WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
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
|
* Display settings (M4) persisted app-side: theme override, Material You
|
||||||
* dynamic colour, and week start. Language is handled separately through
|
* 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 }
|
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
|
* Custom-font tokens per Material typeface role (issue #19). Stored as opaque
|
||||||
* strings — "system", "custom", or a bundled font's token — resolved to a
|
* 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 }
|
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
|
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
* [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() }
|
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
|
* Whether the agenda shows its top range bar — the "showing …" header and
|
||||||
* the session range switcher (v2.11). Default ON.
|
* 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 }
|
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 calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
|
||||||
* the historical hard-coded startup view — so existing users see no change
|
* 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
|
* 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
|
* 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].)
|
* (All-day events ignore this and use [defaultAllDayReminderMinutes].)
|
||||||
*/
|
*/
|
||||||
val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
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 ->
|
store.edit { prefs ->
|
||||||
val current = parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
val current = reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
current.applyOverride(calendarId, override)
|
current.applyReminderOverride(calendarId, override)
|
||||||
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
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).
|
* inherit the global all-day default; present null = no reminder).
|
||||||
*/
|
*/
|
||||||
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
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(
|
suspend fun setCalendarAllDayReminderOverride(
|
||||||
calendarId: Long,
|
calendarId: Long,
|
||||||
override: CalendarReminderOverride,
|
override: ReminderOverride,
|
||||||
) {
|
) {
|
||||||
store.edit { prefs ->
|
store.edit { prefs ->
|
||||||
val current =
|
val current =
|
||||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
current.applyOverride(calendarId, override)
|
current.applyReminderOverride(calendarId, override)
|
||||||
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,6 +739,13 @@ class SettingsPrefs @Inject constructor(
|
|||||||
private fun titleTemplateKey(type: SpecialDateType) =
|
private fun titleTemplateKey(type: SpecialDateType) =
|
||||||
stringPreferencesKey("special_dates_title_${type.name}")
|
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) {
|
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||||
null -> DEFAULT_FORM_FIELDS
|
null -> DEFAULT_FORM_FIELDS
|
||||||
else -> stored.split(',')
|
else -> stored.split(',')
|
||||||
@@ -647,6 +790,7 @@ class SettingsPrefs @Inject constructor(
|
|||||||
companion object {
|
companion object {
|
||||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
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 BRAND_FONT_KEY = stringPreferencesKey("brand_font")
|
||||||
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
|
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
|
||||||
internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp")
|
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_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_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 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 TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||||
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
||||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
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 DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||||
@@ -685,6 +835,10 @@ class SettingsPrefs @Inject constructor(
|
|||||||
stringPreferencesKey("per_calendar_allday_reminder_override")
|
stringPreferencesKey("per_calendar_allday_reminder_override")
|
||||||
internal val DEFAULT_FORM_FIELDS =
|
internal val DEFAULT_FORM_FIELDS =
|
||||||
setOf(EventFormField.Location, EventFormField.Description)
|
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_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
|
||||||
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
|
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
|
||||||
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
|
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
|
||||||
@@ -727,28 +881,6 @@ data class BackupStatus(
|
|||||||
val consecutiveFailures: Int,
|
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
|
* 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
|
* 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]. */
|
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
||||||
private const val WEEK_START_AUTO = "AUTO"
|
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 KEY_VALUE_SEP = "="
|
||||||
private const val LIST_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
|
* 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 =
|
private fun List<Int>.toStoredReminders(): String =
|
||||||
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
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 =
|
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
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.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import androidx.core.net.toUri
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -18,12 +19,14 @@ import javax.inject.Inject
|
|||||||
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
||||||
* alarm), so the receiver is not exported.
|
* alarm), so the receiver is not exported.
|
||||||
*
|
*
|
||||||
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
|
* - **Dismiss** just cancels the notification — the scan's watermark has moved
|
||||||
* already fired, so nothing re-posts it.
|
* past this reminder, so nothing re-posts it.
|
||||||
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
||||||
* it after the user's snooze delay.
|
* it after the user's snooze delay.
|
||||||
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
|
* - **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
|
@AndroidEntryPoint
|
||||||
class ReminderActionReceiver : BroadcastReceiver() {
|
class ReminderActionReceiver : BroadcastReceiver() {
|
||||||
@@ -73,7 +76,14 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
||||||
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
|
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_EVENT_ID = "event_id"
|
||||||
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||||
private const val EXTRA_BEGIN = "begin"
|
private const val EXTRA_BEGIN = "begin"
|
||||||
@@ -82,11 +92,20 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
private const val EXTRA_LOCATION = "location"
|
private const val EXTRA_LOCATION = "location"
|
||||||
private const val EXTRA_ALL_DAY = "all_day"
|
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 names the reminder and carries no information the extras
|
||||||
|
* don't. It is what actually keeps two reminders' `PendingIntent`s
|
||||||
|
* apart: `filterEquals` compares action, component, data, type and
|
||||||
|
* categories — never extras — so without it a request-code collision
|
||||||
|
* would have them share one alarm and one payload.
|
||||||
|
*/
|
||||||
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
||||||
Intent(context, ReminderActionReceiver::class.java).apply {
|
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||||
this.action = action
|
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_EVENT_ID, alert.eventId)
|
||||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||||
@@ -97,23 +116,29 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A stable request code per (alert, action) so the three PendingIntents
|
* A stable request code per (alert, action) so one notification's
|
||||||
* of one notification stay distinct and don't clobber each other.
|
* PendingIntents stay distinct and don't clobber each other.
|
||||||
|
*
|
||||||
|
* The key is a hash now rather than a small row id, so the shift is what
|
||||||
|
* keeps the action slot intact; the top three bits it drops can make two
|
||||||
|
* *different* reminders share a code, which the per-reminder data URI in
|
||||||
|
* [intent] separates.
|
||||||
*/
|
*/
|
||||||
fun requestCode(alert: ReminderAlert, action: String): Int {
|
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||||
val actionOffset = when (action) {
|
val actionOffset = when (action) {
|
||||||
ACTION_SNOOZE -> 1
|
ACTION_SNOOZE -> 1
|
||||||
ACTION_DISMISS -> 2
|
ACTION_DISMISS -> 2
|
||||||
ACTION_SHOW -> 3
|
ACTION_SHOW -> 3
|
||||||
|
ACTION_OPEN -> 4
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
return alert.alertId.toInt() * 8 + actionOffset
|
return (alert.key.toInt() shl 3) + actionOffset
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun alertFrom(intent: Intent): ReminderAlert? {
|
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||||
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
|
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
|
||||||
return ReminderAlert(
|
return ReminderAlert(
|
||||||
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
key = intent.getLongExtra(EXTRA_ALERT_KEY, 0L),
|
||||||
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||||
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
||||||
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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 — the half of delivery that
|
||||||
|
* used to be the provider's (#75).
|
||||||
|
*
|
||||||
|
* Exactly **one** alarm exists at a time, for the earliest reminder still ahead.
|
||||||
|
* Every firing re-scans and re-arms, so a reminder added, moved or deleted in
|
||||||
|
* between is picked up on the next pass instead of needing an alarm per reminder
|
||||||
|
* to be kept in sync with the provider's tables.
|
||||||
|
*
|
||||||
|
* A reminder that lands late is a broken reminder, hence an *exact* alarm; the
|
||||||
|
* inexact allow-while-idle fallback only applies where the OS withholds the
|
||||||
|
* capability (API 31–32 with the user's 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,37 @@
|
|||||||
|
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] that identifies it across a reboot, a re-scan and a reinstall.
|
||||||
|
*
|
||||||
|
* [key] used to be the `CalendarAlerts` row id. It is now derived from the
|
||||||
|
* reminder itself (see [PlannedReminder.key]) because there is no row any more —
|
||||||
|
* in-house delivery reads `Instances` and `Reminders` and owns the alarm (#75).
|
||||||
|
* Everything downstream only ever needed it to be stable and unique, which it
|
||||||
|
* still is: it keys the notification tag, so a reminder posted twice replaces
|
||||||
|
* itself instead of stacking, and it keys the snooze/dismiss `PendingIntent`s.
|
||||||
|
*/
|
||||||
|
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,128 @@
|
|||||||
|
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 the reminder
|
||||||
|
* offsets hanging off them, straight out of the provider's own tables.
|
||||||
|
*
|
||||||
|
* Deliberately *not* `CalendarAlerts`. That table is the provider's own working
|
||||||
|
* copy of this same information, and the whole point of #75's second round is
|
||||||
|
* that we can no longer assume it gets written. `Instances` and `Reminders` are
|
||||||
|
* plain data the app already reads everywhere else.
|
||||||
|
*
|
||||||
|
* An interface so [ReminderScanner] can be exercised on the JVM without a
|
||||||
|
* ContentResolver, in the shape the rest of `data/calendar` uses.
|
||||||
|
*/
|
||||||
|
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 calendar's system flag, which the app's one visibility
|
||||||
|
// model writes (#75) — a switched-off calendar must not plan reminders.
|
||||||
|
// The status clause mirrors CalendarDataSource.instances: a cancelled
|
||||||
|
// single occurrence of a series is a real row, and 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 because the ids go into the selection literally; an unbounded
|
||||||
|
// `IN (...)` on a busy calendar 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,67 @@
|
|||||||
|
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.
|
||||||
|
*
|
||||||
|
* The scan alarm re-arms itself at most a day out, so in the steady state this
|
||||||
|
* finds nothing to do. It exists for the case the whole feature is about — a
|
||||||
|
* device that quietly drops the alarm without a reboot to announce it. The
|
||||||
|
* scheduling half of reminder delivery must not have a single point of failure,
|
||||||
|
* which is exactly what the provider's broadcast turned out to be.
|
||||||
|
*/
|
||||||
|
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; anything reaching here is the
|
||||||
|
// entry point itself, which a retry will not mend. Never fail the chain.
|
||||||
|
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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
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.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.datetime.isoDayNumber
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Posts one notification per due reminder alert on a dedicated channel.
|
* Posts one notification per due reminder on a dedicated channel. Tapping opens
|
||||||
* Tapping opens the event's detail screen; the tag is the alert id, so a
|
* the event's detail screen.
|
||||||
* re-broadcast of an alert we couldn't mark fired replaces its notification
|
*
|
||||||
* silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of
|
* The tag is the reminder's stable key, so a scan that posts the same reminder
|
||||||
* duplicating it.
|
* 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
|
@Singleton
|
||||||
class ReminderNotifier @Inject constructor(
|
class ReminderNotifier @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
private val calendarPrefs: CalendarPrefs,
|
private val calendarPrefs: CalendarPrefs,
|
||||||
|
private val calendarDataSource: CalendarDataSource,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||||
@@ -45,23 +53,45 @@ class ReminderNotifier @Inject constructor(
|
|||||||
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun post(alert: ReminderAlert) {
|
/**
|
||||||
// The single choke point for the disabled-calendar gate: it covers both
|
* The single choke point for "this calendar is switched off". The scan
|
||||||
// the provider broadcast (EventReminderReceiver) and a snoozed re-show
|
* already filters on `Calendars.VISIBLE`, but two paths reach [post] around
|
||||||
// (ReminderActionReceiver), so a calendar disabled after a snooze no
|
* it: a snooze re-shown from its own alarm, armed before the calendar was
|
||||||
// longer notifies — without either receiver duplicating the check.
|
* switched off, and a read-only install whose switch lives app-side
|
||||||
if (alert.isForDisabledCalendar(calendarPrefs.disabledCalendarIds.first())) return
|
* ([CalendarPrefs]) because it may not write the flag. Both are covered here
|
||||||
|
* rather than in either receiver.
|
||||||
|
*/
|
||||||
|
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()
|
ensureChannel()
|
||||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
val is24Hour = settingsPrefs.timeFormat.first()
|
val is24Hour = settingsPrefs.timeFormat.first()
|
||||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
.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(
|
val time = reminderTimeText(
|
||||||
beginMillis = alert.beginMillis,
|
beginMillis = alert.beginMillis,
|
||||||
endMillis = alert.endMillis,
|
endMillis = alert.endMillis,
|
||||||
isAllDay = alert.isAllDay,
|
isAllDay = alert.isAllDay,
|
||||||
zone = ZoneId.systemDefault(),
|
zone = zone,
|
||||||
locale = Locale.getDefault(),
|
locale = locale,
|
||||||
is24Hour = is24Hour,
|
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 text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
@@ -88,16 +118,18 @@ class ReminderNotifier @Inject constructor(
|
|||||||
.build()
|
.build()
|
||||||
try {
|
try {
|
||||||
NotificationManagerCompat.from(context)
|
NotificationManagerCompat.from(context)
|
||||||
.notify(alert.alertId.toString(), NOTIFICATION_ID, notification)
|
.notify(alert.key.toString(), NOTIFICATION_ID, notification)
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
||||||
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
||||||
}
|
}
|
||||||
|
// Handled either way: re-running it would hit the same revoked permission.
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||||
fun cancel(alert: ReminderAlert) {
|
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 =
|
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
||||||
@@ -110,7 +142,11 @@ class ReminderNotifier @Inject constructor(
|
|||||||
|
|
||||||
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
/* requestCode = */ alert.alertId.toInt(),
|
// Shares the per-(alert, action) request-code scheme with the buttons, so
|
||||||
|
// the key's wider value range can't collide one notification's intents.
|
||||||
|
/* requestCode = */ ReminderActionReceiver.requestCode(
|
||||||
|
alert, ReminderActionReceiver.ACTION_OPEN,
|
||||||
|
),
|
||||||
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
|
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
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.
|
||||||
|
*
|
||||||
|
* This is the whole loop. Every trigger — the alarm firing, boot, a clock or
|
||||||
|
* timezone change, an edit landing in the provider, the app starting, the daily
|
||||||
|
* safety net — runs the same [scan], so there is a single path to reason about
|
||||||
|
* and no ordering between triggers to get wrong. Re-running it 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) {
|
||||||
|
// The calendar permission was revoked mid-flight. Nothing to
|
||||||
|
// re-arm and nothing to recover from — 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 everything missed meanwhile.
|
||||||
|
alarms.cancelScan()
|
||||||
|
state.setLastScanMillis(now)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
|
||||||
|
// Reach a little into the past as well: an event already under way can
|
||||||
|
// still owe a reminder (an all-day "at time of event" encodes to a
|
||||||
|
// negative offset, which fires after begin), and a catch-up pass needs
|
||||||
|
// to see the occurrences whose moment 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 regardless of whether anything could be posted: a user who
|
||||||
|
// muted notifications is not owed a backlog when they unmute.
|
||||||
|
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 an event saved or deleted in the app
|
||||||
|
* re-arms the alarm immediately rather than waiting for the next pass.
|
||||||
|
*
|
||||||
|
* Debounced: a single save writes the event, its reminders and its
|
||||||
|
* attendees, and each lands as its own notification. Only useful while the
|
||||||
|
* process is alive — every other trigger covers the rest, which is why
|
||||||
|
* nothing here needs to survive it.
|
||||||
|
*/
|
||||||
|
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, even with nothing
|
||||||
|
* pending: 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,63 @@
|
|||||||
|
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 reason to re-run a reminder scan that arrives from outside the process.
|
||||||
|
*
|
||||||
|
* All of them do the same thing, because [ReminderScanner.scan] is idempotent
|
||||||
|
* and works out what is owed from its watermark rather than from why it was
|
||||||
|
* called:
|
||||||
|
*
|
||||||
|
* - **our own alarm** ([ACTION_SCAN]) — the ordinary case, a reminder is due;
|
||||||
|
* - **boot** and **package replaced** — both wipe pending alarms, so the app has
|
||||||
|
* to re-arm or reminders stop silently, which is the failure #75 is about;
|
||||||
|
* - **time and timezone changes** — they move every reminder relative to the
|
||||||
|
* armed alarm, and an all-day reminder's fire hour is recomposed in the
|
||||||
|
* current zone, so both need a fresh plan.
|
||||||
|
*
|
||||||
|
* Exported because the system broadcasts arrive from outside. [ACTION_SCAN] is
|
||||||
|
* ours and always sent as an explicit intent; another app triggering a scan
|
||||||
|
* early would only make it re-read the provider and re-arm, which is harmless.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class ReminderScheduleReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@Inject lateinit var scanner: ReminderScanner
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
// Every action here does the same thing, but the filter still has to be
|
||||||
|
// checked: the receiver is exported, and the system broadcasts it takes
|
||||||
|
// are protected, so an intent arriving with 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,9 +12,12 @@ 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.
|
||||||
*
|
*
|
||||||
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
|
* Separate from [ReminderAlarmScheduler]'s scan alarm, and deliberately so: a
|
||||||
* broadcast (the Etar model), but a snoozed reminder has no provider backing —
|
* snooze is pinned to one reminder at a time the user chose, while the scan
|
||||||
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
|
* alarm is a single moving wake-up for whatever comes next. A re-show also has
|
||||||
|
* to outlive the scan's watermark moving past that reminder, so it carries the
|
||||||
|
* reminder in its own intent rather than re-deriving it.
|
||||||
|
*
|
||||||
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
|
* 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
|
* 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).
|
* exact-alarm capability (API 31–32 where the user revoked it).
|
||||||
|
|||||||
@@ -1,23 +1,37 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
|
import java.time.DayOfWeek
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
|
import java.time.format.TextStyle
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one line of time context in a reminder notification. Pure so it can be
|
* 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 events that fall on a day other than [today] are prefixed with that
|
||||||
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time)
|
* 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, one day: "11 Jun 2026"
|
||||||
* - all-day, multi-day: "11 Jun 2026 – 12 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.
|
* read in UTC and the end day is the last *covered* day.
|
||||||
*/
|
*/
|
||||||
fun reminderTimeText(
|
fun reminderTimeText(
|
||||||
@@ -27,6 +41,10 @@ fun reminderTimeText(
|
|||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
is24Hour: Boolean,
|
is24Hour: Boolean,
|
||||||
|
today: LocalDate,
|
||||||
|
firstDayOfWeek: DayOfWeek,
|
||||||
|
tomorrowLabel: String,
|
||||||
|
yesterdayLabel: String,
|
||||||
): String {
|
): String {
|
||||||
if (isAllDay) {
|
if (isAllDay) {
|
||||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
@@ -43,18 +61,70 @@ fun reminderTimeText(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
||||||
|
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
||||||
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
||||||
return if (begin.toLocalDate() == end.toLocalDate()) {
|
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 {
|
} else {
|
||||||
// Cross-day: medium date + the chosen short time, joined per side. Built
|
// Cross-day: medium date + the chosen short time, joined per side. Built
|
||||||
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
|
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
|
||||||
// applies to the time portion too.
|
// applies to the time portion too. The explicit dates already say which
|
||||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
// day, so no relative prefix is layered on top.
|
||||||
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
|
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
|
||||||
dateTime(begin) + RANGE + dateTime(end)
|
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 = " – "
|
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,76 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ways a calendar can behave unlike a plain, writable one — each of them a
|
||||||
|
* reason it is missing from the event and import pickers, and each of them
|
||||||
|
* something the app knows and used to keep to itself (#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,
|
||||||
|
* which is why it is the one exclusion with nothing else to give it away.
|
||||||
|
*/
|
||||||
|
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 this calendar belongs to keeps its events off the device
|
||||||
|
* (`Calendars.SYNC_EVENTS = 0`) — an "empty by construction" calendar: the rows
|
||||||
|
* simply aren't here, so nothing can display them and no reminder can fire.
|
||||||
|
*
|
||||||
|
* Device-local calendars are excluded deliberately. Nothing syncs them by
|
||||||
|
* definition, so the flag says nothing about them, and a local calendar from
|
||||||
|
* another app can hold real events at `sync_events = 0` — the same unsoundness
|
||||||
|
* that made the #75 migration guard wrong.
|
||||||
|
*/
|
||||||
|
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: there are no events on the device
|
||||||
|
* to reveal, so the switch would be a control that does nothing.
|
||||||
|
*/
|
||||||
|
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):
|
||||||
|
*
|
||||||
|
* - read-only has nowhere to write;
|
||||||
|
* - switched off would hide the event the moment it was saved;
|
||||||
|
* - a managed mirror has the next contact sync delete it;
|
||||||
|
* - a non-syncing one never carries the event up to the account, and
|
||||||
|
* `CalendarProvider2` wipes the calendar's rows outright when the
|
||||||
|
* subscription is switched back on — a saved event is a dead end either way.
|
||||||
|
*
|
||||||
|
* This is the test for *targets*. 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,58 @@
|
|||||||
|
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] — calendars switched off in Settings →
|
||||||
|
* Calendars while the app could not write `Calendars.VISIBLE`, plus whatever
|
||||||
|
* the retired app-local visibility model left behind (#75) — against the
|
||||||
|
* calendars actually on the device.
|
||||||
|
*
|
||||||
|
* The plan only ever *hides*. Switching a calendar off is intent the user
|
||||||
|
* expressed in Calendula, so carrying it into the provider is fair. The other
|
||||||
|
* direction is deliberately absent: a calendar hidden at system level was hidden
|
||||||
|
* somewhere else (another calendar app, the account's own settings), and
|
||||||
|
* switching it back on would un-hide it there too *and* start firing reminders
|
||||||
|
* nobody asked for. Calendula follows that flag instead and explains itself once
|
||||||
|
* (see [hasSystemHiddenCalendars]).
|
||||||
|
*
|
||||||
|
* [CalendarVisibilityPlan.settled] carries the ids that need no write — already
|
||||||
|
* hidden, or gone from the device. They leave the pending set exactly as a
|
||||||
|
* successful write would.
|
||||||
|
*/
|
||||||
|
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]
|
||||||
|
// No row means the calendar is gone; already invisible means someone
|
||||||
|
// (us, on an earlier run) got there first. Either way: 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. Those calendars showed their events before the app adopted
|
||||||
|
* `Calendars.VISIBLE` as its one visibility model and no longer do, which is
|
||||||
|
* what the one-time notice explains — the alternative, switching them on, would
|
||||||
|
* reach into every other calendar app on the device.
|
||||||
|
*/
|
||||||
|
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.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User input for creating an event (and, from v1.3, editing one). Times are
|
* 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
|
* wall-clock values in [timezone]; the data layer translates them to provider
|
||||||
* provider millis (all-day events normalise to UTC midnights there).
|
* millis (all-day events normalise to UTC midnights there).
|
||||||
*/
|
*/
|
||||||
data class EventForm(
|
data class EventForm(
|
||||||
val calendarId: Long?,
|
val calendarId: Long?,
|
||||||
@@ -18,6 +19,23 @@ data class EventForm(
|
|||||||
val isAllDay: Boolean = false,
|
val isAllDay: Boolean = false,
|
||||||
val start: LocalDateTime,
|
val start: LocalDateTime,
|
||||||
val end: 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 location: String = "",
|
||||||
val description: String = "",
|
val description: String = "",
|
||||||
/** Reminder lead times in minutes before the start, deduplicated. */
|
/** 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 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 {
|
enum class EventFormField {
|
||||||
Location,
|
Location,
|
||||||
Description,
|
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,
|
Reminders,
|
||||||
Recurrence,
|
Recurrence,
|
||||||
Availability,
|
Availability,
|
||||||
@@ -100,8 +126,25 @@ enum class EventFormProblem {
|
|||||||
* All-day provider times are UTC midnights with an exclusive end; the form
|
* 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
|
* shows the last covered day and keeps placeholder wall-clock times in case
|
||||||
* the user switches the event to timed.
|
* 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 {
|
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 (start, end) = if (instance.isAllDay) {
|
||||||
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
||||||
.toLocalDateTime(TimeZone.UTC).date
|
.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))
|
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||||
} else {
|
} else {
|
||||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to
|
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
|
||||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone)
|
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
|
||||||
}
|
}
|
||||||
return EventForm(
|
return EventForm(
|
||||||
calendarId = instance.calendarId,
|
calendarId = instance.calendarId,
|
||||||
@@ -119,6 +162,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
|||||||
isAllDay = instance.isAllDay,
|
isAllDay = instance.isAllDay,
|
||||||
start = start,
|
start = start,
|
||||||
end = end,
|
end = end,
|
||||||
|
timezone = pinnedZone,
|
||||||
location = instance.location.orEmpty(),
|
location = instance.location.orEmpty(),
|
||||||
description = description.orEmpty(),
|
description = description.orEmpty(),
|
||||||
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
||||||
@@ -176,6 +220,23 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
|||||||
rowEnd = instance.end,
|
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
|
* The optional sections that hold a value in [form] — when editing, these
|
||||||
* must be visible regardless of the user's default-fields setting, or the
|
* must be visible regardless of the user's default-fields setting, or the
|
||||||
@@ -184,6 +245,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
|||||||
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||||
if (location.isNotBlank()) add(EventFormField.Location)
|
if (location.isNotBlank()) add(EventFormField.Location)
|
||||||
if (description.isNotBlank()) add(EventFormField.Description)
|
if (description.isNotBlank()) add(EventFormField.Description)
|
||||||
|
if (timezone != null) add(EventFormField.Timezone)
|
||||||
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
||||||
if (rrule != null) add(EventFormField.Recurrence)
|
if (rrule != null) add(EventFormField.Recurrence)
|
||||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a prefilled [EventForm] from an `ACTION_INSERT` intent's extras (issue
|
||||||
|
* #30). External apps and widgets (e.g. the Todo Agenda widget) launch the
|
||||||
|
* calendar this way to create a new event, passing the fields as
|
||||||
|
* [android.provider.CalendarContract] extras. Any field the intent omits falls
|
||||||
|
* back to the same defaults the in-app "new event" uses — a timed start at the
|
||||||
|
* next full hour and a one-hour duration. [EventForm.calendarId] is left null so
|
||||||
|
* it resolves to the last-used / first-writable calendar, exactly like the
|
||||||
|
* `.ics` single-event and plain new-event paths.
|
||||||
|
*
|
||||||
|
* Pure (no Android types) so it is unit-testable; the intent parsing that reads
|
||||||
|
* the extras lives in `MainActivity.insertFormOrNull`.
|
||||||
|
*/
|
||||||
|
fun buildInsertEventForm(
|
||||||
|
beginMillis: Long?,
|
||||||
|
endMillis: Long?,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
title: String?,
|
||||||
|
description: String?,
|
||||||
|
location: String?,
|
||||||
|
rrule: String?,
|
||||||
|
zone: TimeZone,
|
||||||
|
now: Instant,
|
||||||
|
): EventForm {
|
||||||
|
val (start, end) = if (isAllDay) {
|
||||||
|
// All-day provider times are UTC midnights with an exclusive end; show
|
||||||
|
// the last covered day and keep placeholder wall-clock times in case the
|
||||||
|
// user switches the event to timed (mirrors EventDetail.toEditForm).
|
||||||
|
val startDate = beginMillis
|
||||||
|
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||||
|
?: now.toLocalDateTime(zone).date
|
||||||
|
val endDate = endMillis
|
||||||
|
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||||
|
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
|
||||||
|
?: startDate
|
||||||
|
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||||
|
} else {
|
||||||
|
val startTime = beginMillis
|
||||||
|
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
|
||||||
|
?: nextFullHour(now, zone)
|
||||||
|
val endTime = endMillis
|
||||||
|
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
|
||||||
|
?.takeIf { it >= startTime }
|
||||||
|
?: (startTime.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
||||||
|
startTime to endTime
|
||||||
|
}
|
||||||
|
return EventForm(
|
||||||
|
calendarId = null,
|
||||||
|
title = title.orEmpty(),
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
location = location.orEmpty(),
|
||||||
|
description = description.orEmpty(),
|
||||||
|
// Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:"
|
||||||
|
// some callers include.
|
||||||
|
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextFullHour(now: Instant, zone: TimeZone): LocalDateTime {
|
||||||
|
val hourMillis = 3_600_000L
|
||||||
|
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
|
||||||
|
return Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
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
|
import kotlin.time.Instant
|
||||||
|
|
||||||
data class CalendarSource(
|
data class CalendarSource(
|
||||||
@@ -8,6 +12,13 @@ data class CalendarSource(
|
|||||||
val accountName: String,
|
val accountName: String,
|
||||||
val accountType: String,
|
val accountType: String,
|
||||||
val color: Int,
|
val color: Int,
|
||||||
|
/**
|
||||||
|
* The system's per-calendar `Calendars.VISIBLE` flag — the single visibility
|
||||||
|
* model: it decides both what Calendula shows and whether the provider
|
||||||
|
* schedules this calendar's reminder alarms at all (#75). Settings →
|
||||||
|
* Calendars writes it; the drawer's filter sheet is a separate, purely
|
||||||
|
* in-app declutter that leaves reminders alone.
|
||||||
|
*/
|
||||||
val isVisibleInSystem: Boolean,
|
val isVisibleInSystem: Boolean,
|
||||||
/**
|
/**
|
||||||
* Whether events in this calendar can be created/edited/deleted
|
* Whether events in this calendar can be created/edited/deleted
|
||||||
@@ -34,6 +45,15 @@ data class CalendarSource(
|
|||||||
* even after a backup restore clears the app's stored ids.
|
* even after a backup restore clears the app's stored ids.
|
||||||
*/
|
*/
|
||||||
val isManaged: Boolean = false,
|
val isManaged: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Whether the provider keeps this calendar's events on the device
|
||||||
|
* (`Calendars.SYNC_EVENTS`). Independent of [isVisibleInSystem]. For a
|
||||||
|
* synced account it means the events aren't stored locally at all, so the
|
||||||
|
* calendar reads as permanently empty; a device-local calendar another app
|
||||||
|
* created can hold events with the flag off, so it says nothing there.
|
||||||
|
* Read for the "not synced" row label (#76).
|
||||||
|
*/
|
||||||
|
val syncsEvents: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class EventInstance(
|
data class EventInstance(
|
||||||
@@ -56,6 +76,35 @@ data class EventInstance(
|
|||||||
*/
|
*/
|
||||||
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone this event's calendar dates live in. Timed events are resolved in the
|
||||||
|
* device [zone]; all-day events live at UTC midnights with an exclusive end, so
|
||||||
|
* resolving them anywhere else shifts the day boundaries — east of UTC the end
|
||||||
|
* leaks onto the following day (#65), west of UTC the start pulls back onto the
|
||||||
|
* previous one (#82). Every surface that has to name an all-day event's date
|
||||||
|
* goes through here, so grid, agenda, detail and search cannot disagree.
|
||||||
|
*/
|
||||||
|
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 actually occupies. An event ending exactly at
|
||||||
|
* midnight (all-day events end at the exclusive next-midnight) does not reach
|
||||||
|
* into that boundary day, so resolve the instant 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(
|
data class EventDetail(
|
||||||
val instance: EventInstance,
|
val instance: EventInstance,
|
||||||
val description: String?,
|
val description: String?,
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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]
|
||||||
|
* (the event's own start date, i.e. DTSTART), for showing a rule as dates
|
||||||
|
* instead of as words.
|
||||||
|
*
|
||||||
|
* A recurrence rule is the one place in the editor where a correct-sounding
|
||||||
|
* phrase can still mean something the user didn't intend — "every 2 weeks on
|
||||||
|
* Mon, Fri", "monthly" on the 31st — and the only honest answer is the dates
|
||||||
|
* themselves. This is a *preview*, deliberately kept to the shapes the picker
|
||||||
|
* can build; the provider remains the authority on what actually gets stored
|
||||||
|
* and expanded.
|
||||||
|
*
|
||||||
|
* The rules it mirrors, all from RFC 5545 (the spec the platform's recurrence
|
||||||
|
* engine implements):
|
||||||
|
* - a monthly or yearly rule **skips** a period the start day doesn't exist in,
|
||||||
|
* rather than sliding to the month's last day: the 31st recurring monthly
|
||||||
|
* lands only in 31-day months, 29 February yearly only in leap years.
|
||||||
|
* - a weekly rule with weekday picks repeats in blocks of `interval` weeks,
|
||||||
|
* with the week beginning on **Monday** — the RFC's default WKST, and the one
|
||||||
|
* that applies here because [toRRule] never writes a WKST part. The
|
||||||
|
* display-only "first day of week" preference deliberately doesn't enter into
|
||||||
|
* it, or the preview would disagree with the stored rule.
|
||||||
|
* - [RecurrenceEnd.Count] counts real occurrences (skipped periods don't
|
||||||
|
* consume one), [RecurrenceEnd.Until] is inclusive of its date.
|
||||||
|
*
|
||||||
|
* Returns fewer than [limit] dates when the series ends first, and an empty
|
||||||
|
* list when the rule yields nothing at all (an UNTIL before the 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()
|
||||||
|
|
||||||
|
val result = mutableListOf<LocalDate>()
|
||||||
|
var period = 0
|
||||||
|
// Periods that produce nothing (a skipped 31st, a weekday block whose picks
|
||||||
|
// all fall before the start) must not stall the walk, so the cap is on
|
||||||
|
// periods examined rather than on 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 the day into the shorter month; the rule instead
|
||||||
|
// 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.
|
||||||
|
*
|
||||||
|
* Day and month alone are enough for a rule that stays inside one year, and
|
||||||
|
* dropping the year keeps the line short. They are actively misleading
|
||||||
|
* otherwise: a yearly rule reads as the same date three times over, and a
|
||||||
|
* monthly one rolling past December gives no hint that it has.
|
||||||
|
*/
|
||||||
|
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 (29 February yearly with a 999-year
|
||||||
|
* interval is nonsense; 31st monthly needs at most a handful), and 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,258 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Works out *when* each reminder has to fire and *which* ones are due, with no
|
||||||
|
* provider and no clock of its own — the whole decision layer of in-house
|
||||||
|
* reminder delivery (#75).
|
||||||
|
*
|
||||||
|
* Calendula used to leave both halves to the calendar provider: it scheduled the
|
||||||
|
* alarms, wrote the `CalendarAlerts` rows, and broadcast `EVENT_REMINDER` at the
|
||||||
|
* right moment. That chain is intact on stock Android but demonstrably not on
|
||||||
|
* every device — AOSP's own unbundled calendar carries three separate
|
||||||
|
* workarounds for OEMs that retarget the broadcast, or that only write the alert
|
||||||
|
* row at alert time. An app that can only *react* to that broadcast has no way
|
||||||
|
* to notice it never came.
|
||||||
|
*
|
||||||
|
* So the offsets in `CalendarContract.Reminders` are now read as data and turned
|
||||||
|
* into alarms we own. Everything here is pure: instances and reminder offsets in,
|
||||||
|
* fire instants out.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 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 of this reminder, derived from what defines it rather
|
||||||
|
* than from a provider row id (there is none any more). It keys the
|
||||||
|
* notification tag and the snooze/dismiss `PendingIntent`s, so it has to
|
||||||
|
* survive a reboot, a re-scan and a reinstall — the same reminder must land
|
||||||
|
* on the same notification instead of stacking a second one.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* A **timed** occurrence is trivial: `begin` is an absolute instant, so
|
||||||
|
* `begin − minutes` is exact by construction, in any timezone, across any DST
|
||||||
|
* boundary.
|
||||||
|
*
|
||||||
|
* An **all-day** occurrence is not, and taking the offset at face value is what
|
||||||
|
* makes reminders land at the wrong hour. Its `begin` is UTC midnight, and the
|
||||||
|
* stored offset is not a plain lead time — `AllDayReminderEncoding` folds the
|
||||||
|
* wanted wall-clock hour into it, sampled against *one* date's UTC offset. Fire
|
||||||
|
* at `begin − minutes` and every occurrence in a different DST phase than the one
|
||||||
|
* that was sampled drifts by the offset delta, an hour early in one direction and
|
||||||
|
* an hour late in the other. Rows written by other apps carry no wall-clock at
|
||||||
|
* all — a conventional `1440` fires at UTC midnight, which is 01:00 or 02:00
|
||||||
|
* local in Berlin and the wrong day west of UTC.
|
||||||
|
*
|
||||||
|
* So the offset is only read for *which day* it means, via [allDayLeadDays], and
|
||||||
|
* the hour comes from [allDayTimeMinutes] — the one global "show all-day
|
||||||
|
* reminders at" setting — recomposed against each occurrence's own date in
|
||||||
|
* [zone]. 09:00 Berlin is then 09:00 Berlin on every occurrence, whatever the
|
||||||
|
* offset was when the row was written.
|
||||||
|
*
|
||||||
|
* [minutesByEvent] may hold duplicate offsets (two identical reminder rows on one
|
||||||
|
* event); they collapse, because they would otherwise fight over one notification.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* Normally the local date of the encoded fire instant answers it: our own rows
|
||||||
|
* fold the wanted hour into the offset, so that date *is* the day the reminder
|
||||||
|
* belongs to, whatever the offset was when it was written.
|
||||||
|
*
|
||||||
|
* A plain multiple of 1440 is the exception. Those come from calendar apps that
|
||||||
|
* store a bare lead time against UTC midnight, and east of UTC both readings
|
||||||
|
* agree anyway — but west of UTC the instant falls on the previous local date,
|
||||||
|
* so the day count has to be taken at face value or it comes out one too many.
|
||||||
|
*
|
||||||
|
* Except when the row is one of ours after all: our offset lands on a multiple
|
||||||
|
* whenever the all-day hour equals the zone's UTC offset (20:00 in New York,
|
||||||
|
* 19:00 an hour further west, and so on), and reading those at face value fired
|
||||||
|
* them a day late — a "1 day before" arriving on the morning of the event. So
|
||||||
|
* the hour decides, not the shape of the number: an instant landing on the hour
|
||||||
|
* the setting names is ours. Within an hour or so of it counts, because a fixed
|
||||||
|
* offset written in one DST phase drifts by the offset delta in the other.
|
||||||
|
*
|
||||||
|
* The two are genuinely indistinguishable in that band — the encodings collide
|
||||||
|
* exactly there — so the tie goes to the reading that honours the lead time the
|
||||||
|
* user chose in this app.
|
||||||
|
*
|
||||||
|
* [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes] calls
|
||||||
|
* this for display, so the notification arrives on the day the event screen says
|
||||||
|
* it will.
|
||||||
|
*/
|
||||||
|
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]` — a
|
||||||
|
* half-open watermark, so a scan triggered twice cannot post the same reminder
|
||||||
|
* twice, while a scan that runs late still catches everything the missed alarm
|
||||||
|
* would have posted. That catch-up is the point: an alarm dropped by a reboot,
|
||||||
|
* an app update or a doze window is recovered by the next scan rather than lost.
|
||||||
|
*
|
||||||
|
* A reminder whose event has already ended is dropped rather than posted late —
|
||||||
|
* see [isStillRelevant].
|
||||||
|
*
|
||||||
|
* [nextAlarmMillis] is capped at [horizonMillis] even when nothing is pending, so
|
||||||
|
* the scan re-runs at least that often and the lookahead window rolls 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, given what the
|
||||||
|
* last one recorded.
|
||||||
|
*
|
||||||
|
* A first-ever scan ([lastScanMillis] `null`) claims the present, so an install
|
||||||
|
* or an upgrade onto in-house delivery does not treat every reminder since the
|
||||||
|
* epoch as overdue and bury the user in notifications. A watermark in the
|
||||||
|
* *future* — the clock was moved back, or the user travelled across the date
|
||||||
|
* line — is clamped for the mirror-image reason: left alone it would silence
|
||||||
|
* every reminder until real time caught up with it.
|
||||||
|
*/
|
||||||
|
fun reminderWatermark(lastScanMillis: Long?, nowMillis: Long): Long =
|
||||||
|
lastScanMillis?.coerceAtMost(nowMillis) ?: nowMillis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far ahead instances must be queried for [scheduleReminders] to see every
|
||||||
|
* reminder in time: the plain lookahead plus the longest offset any reminder row
|
||||||
|
* carries, so a "two weeks before" reminder is planned before it comes due
|
||||||
|
* instead of firing late (the limitation Etar's equivalent documents).
|
||||||
|
*
|
||||||
|
* The stretch is capped at [MAX_REMINDER_LEAD_MILLIS]. `maxReminderMinutes` is
|
||||||
|
* whatever the largest row in the whole provider says, across every calendar and
|
||||||
|
* whoever wrote it — an imported `TRIGGER:-P100W`, or a sync adapter writing
|
||||||
|
* nonsense, would otherwise make every scan (launch, every provider change, every
|
||||||
|
* alarm) expand every recurring series over years. A lead beyond the cap is not
|
||||||
|
* delivered; a year is far past anything the picker composes.
|
||||||
|
*/
|
||||||
|
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.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
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.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.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
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.day.DayScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
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.imports.ImportScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
||||||
@@ -72,6 +74,10 @@ fun CalendarHost(
|
|||||||
onWidgetNavConsumed: () -> Unit = {},
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
requestedImportUri: android.net.Uri? = null,
|
requestedImportUri: android.net.Uri? = null,
|
||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
|
requestedInsertForm: EventForm? = null,
|
||||||
|
onInsertConsumed: () -> Unit = {},
|
||||||
|
requestedEditKey: LongArray? = null,
|
||||||
|
onEditKeyConsumed: () -> Unit = {},
|
||||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
// Wait for the persisted default view before seeding the stack, so the app
|
// Wait for the persisted default view before seeding the stack, so the app
|
||||||
@@ -83,6 +89,8 @@ fun CalendarHost(
|
|||||||
// sensible non-empty initial values, so they're ready before the first frame.
|
// sensible non-empty initial values, so they're ready before the first frame.
|
||||||
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
|
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
|
||||||
val drawerViewOrder = viewModel.drawerViewOrder.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) {
|
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||||
mutableStateOf(listOf(defaultView))
|
mutableStateOf(listOf(defaultView))
|
||||||
@@ -141,6 +149,11 @@ fun CalendarHost(
|
|||||||
// over Settings and survives view switches.
|
// over Settings and survives view switches.
|
||||||
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Backup & restore (#69) — like the manager, driven by the calendar list
|
||||||
|
// rather than by preferences, so it is hoisted here instead of living as a
|
||||||
|
// Settings sub-section. Reached from Settings and from the manager.
|
||||||
|
var showBackup by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
// Event form (v1.2 create) — same held-key pattern as the detail screen:
|
// Event form (v1.2 create) — same held-key pattern as the detail screen:
|
||||||
// [heldCreateIso] keeps the prefill date alive through the slide-out.
|
// [heldCreateIso] keeps the prefill date alive through the slide-out.
|
||||||
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
|
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
|
||||||
@@ -170,12 +183,32 @@ fun CalendarHost(
|
|||||||
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||||
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||||
var importForm by remember { mutableStateOf<EventForm?>(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) {
|
LaunchedEffect(requestedImportUri) {
|
||||||
if (requestedImportUri != null) {
|
if (requestedImportUri != null) {
|
||||||
importUri = requestedImportUri
|
importUri = requestedImportUri
|
||||||
|
importForceMany = false
|
||||||
onImportConsumed()
|
onImportConsumed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// An external ACTION_INSERT launch (another app/widget creating an event,
|
||||||
|
// issue #30) arrives already prefilled — open it in the same create form the
|
||||||
|
// single-event .ics path uses. [importForm] is the topmost overlay, so it
|
||||||
|
// reveals on top of whatever was open without extra dismissal.
|
||||||
|
LaunchedEffect(requestedInsertForm) {
|
||||||
|
if (requestedInsertForm != null) {
|
||||||
|
importFormSource = ImportSource.Insert
|
||||||
|
importForm = requestedInsertForm
|
||||||
|
onInsertConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Close every overlay that can sit over the calendar, so an externally
|
// Close every overlay that can sit over the calendar, so an externally
|
||||||
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
||||||
@@ -183,12 +216,27 @@ fun CalendarHost(
|
|||||||
fun dismissCoveringOverlays() {
|
fun dismissCoveringOverlays() {
|
||||||
showSettings = false
|
showSettings = false
|
||||||
showCalendars = false
|
showCalendars = false
|
||||||
|
showBackup = false
|
||||||
detailKey = null
|
detailKey = null
|
||||||
editKey = null
|
editKey = null
|
||||||
importUri = null
|
importUri = null
|
||||||
importForm = 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
|
// 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
|
// event's detail, or start a create. Handled once and cleared, mirroring
|
||||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||||
@@ -252,8 +300,8 @@ fun CalendarHost(
|
|||||||
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
||||||
// so back there falls through to the system and exits the app.
|
// so back there falls through to the system and exits the app.
|
||||||
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
||||||
editKey != null || showSettings || showCalendars || importUri != null ||
|
editKey != null || showSettings || showCalendars || showBackup ||
|
||||||
importForm != null
|
importUri != null || importForm != null
|
||||||
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
||||||
viewStack = viewStack.dropLast(1)
|
viewStack = viewStack.dropLast(1)
|
||||||
}
|
}
|
||||||
@@ -262,7 +310,7 @@ fun CalendarHost(
|
|||||||
// Switching between the peer views (month/week/day/agenda) is lateral
|
// Switching between the peer views (month/week/day/agenda) is lateral
|
||||||
// navigation, so it fades through rather than sliding — paging *within* a
|
// navigation, so it fades through rather than sliding — paging *within* a
|
||||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||||
val viewSwitch = calendarFadeThrough()
|
val viewSwitch = fadeThrough()
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = view,
|
targetState = view,
|
||||||
transitionSpec = { viewSwitch },
|
transitionSpec = { viewSwitch },
|
||||||
@@ -272,12 +320,14 @@ fun CalendarHost(
|
|||||||
CalendarView.Week -> WeekScreen(
|
CalendarView.Week -> WeekScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
drawerViewOrder = drawerViewOrder,
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
CalendarView.Day -> DayScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
@@ -289,26 +339,31 @@ fun CalendarHost(
|
|||||||
initialDateIso = pendingDayIso,
|
initialDateIso = pendingDayIso,
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
drawerViewOrder = drawerViewOrder,
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
)
|
)
|
||||||
CalendarView.Month -> MonthScreen(
|
CalendarView.Month -> MonthScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onOpenSettings = onOpenSettings,
|
|
||||||
onOpenSearch = onOpenSearch,
|
|
||||||
onCreateEvent = onCreateEvent,
|
|
||||||
quickSwitchViews = quickSwitchViews,
|
|
||||||
drawerViewOrder = drawerViewOrder,
|
|
||||||
)
|
|
||||||
CalendarView.Agenda -> AgendaScreen(
|
|
||||||
selectedView = currentView,
|
|
||||||
onSelectView = onSelectView,
|
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
drawerViewOrder = drawerViewOrder,
|
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,6 +398,14 @@ fun CalendarHost(
|
|||||||
heldEditKey = key
|
heldEditKey = key
|
||||||
editKey = 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
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,6 +422,7 @@ fun CalendarHost(
|
|||||||
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||||
onClose = { createDateIso = null },
|
onClose = { createDateIso = null },
|
||||||
onSaved = { createDateIso = null },
|
onSaved = { createDateIso = null },
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -378,6 +442,7 @@ fun CalendarHost(
|
|||||||
editKey = null
|
editKey = null
|
||||||
detailKey = null
|
detailKey = null
|
||||||
},
|
},
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,26 +456,21 @@ fun CalendarHost(
|
|||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
onBack = { showSettings = false },
|
onBack = { showSettings = false },
|
||||||
onManageCalendars = { showCalendars = true },
|
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
|
// Import flow for an opened/received .ics file. A single event routes
|
||||||
// into the create form (prefilled, for review); many open the picker.
|
// into the create form (prefilled, for review); many open the picker.
|
||||||
importUri?.let { uri ->
|
importUri?.let { uri ->
|
||||||
ImportScreen(
|
ImportScreen(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
|
forceMany = importForceMany,
|
||||||
onClose = { importUri = null },
|
onClose = { importUri = null },
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
onOpenSingle = { form ->
|
onOpenSingle = { form ->
|
||||||
importUri = null
|
importUri = null
|
||||||
|
importFormSource = ImportSource.File
|
||||||
importForm = form
|
importForm = form
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -419,8 +479,42 @@ fun CalendarHost(
|
|||||||
EventEditScreen(
|
EventEditScreen(
|
||||||
initialDateIso = null,
|
initialDateIso = null,
|
||||||
initialForm = form,
|
initialForm = form,
|
||||||
|
initialFormSource = importFormSource,
|
||||||
onClose = { importForm = null },
|
onClose = { importForm = null },
|
||||||
onSaved = { importForm = null },
|
onSaved = { importForm = null },
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar manager — declared last so it covers every overlay that can
|
||||||
|
// open it: Settings, both event forms, and the .ics import picker (#76).
|
||||||
|
// Coming back from it leaves the caller exactly as it was, with the
|
||||||
|
// calendar list already refreshed by the provider's notification.
|
||||||
|
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 },
|
||||||
|
// Restoring runs the normal .ics import, and that way round this
|
||||||
|
// screen has to step aside: declared above the import overlays,
|
||||||
|
// it would otherwise cover the screen it just asked for. Closing
|
||||||
|
// it hands the user back to whatever opened Backup once the
|
||||||
|
// import is done.
|
||||||
|
onImport = { importUri = it; importForceMany = true; showBackup = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,4 +45,12 @@ class CalendarHostViewModel @Inject constructor(
|
|||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = IMPLEMENTED_VIEWS,
|
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.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -21,6 +22,8 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
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.PermissionScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||||
@@ -35,6 +38,10 @@ fun RootScreen(
|
|||||||
onWidgetNavConsumed: () -> Unit = {},
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
requestedImportUri: android.net.Uri? = null,
|
requestedImportUri: android.net.Uri? = null,
|
||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
|
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||||
|
onInsertConsumed: () -> Unit = {},
|
||||||
|
requestedEditKey: LongArray? = null,
|
||||||
|
onEditKeyConsumed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
@@ -44,6 +51,10 @@ fun RootScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether the app came up already holding it — a launch scan has covered
|
||||||
|
// that case, so only a grant made during this session owes a re-scan.
|
||||||
|
val grantedAtLaunch = remember { hasPermission }
|
||||||
|
|
||||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
DisposableEffect(lifecycle) {
|
DisposableEffect(lifecycle) {
|
||||||
val obs = LifecycleEventObserver { _, event ->
|
val obs = LifecycleEventObserver { _, event ->
|
||||||
@@ -74,6 +85,20 @@ fun RootScreen(
|
|||||||
// frame instead of flashing the wrong screen.
|
// frame instead of flashing the wrong screen.
|
||||||
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||||
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||||
|
// One-time explainer for the switch to the device's own calendar
|
||||||
|
// visibility (#75); armed by the reconciler, shown over the app.
|
||||||
|
val visibilityNotice: CalendarVisibilityNoticeViewModel = hiltViewModel()
|
||||||
|
val noticePending by visibilityNotice.pending.collectAsStateWithLifecycle()
|
||||||
|
// Runs on entry however the permission was granted — including from
|
||||||
|
// Android's app-settings screen, which only comes back through the
|
||||||
|
// ON_RESUME check above. Cheap once there is nothing left to do.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
visibilityNotice.reconcile()
|
||||||
|
if (!grantedAtLaunch) reminderOnboarding.rearmAfterGrant()
|
||||||
|
}
|
||||||
|
if (onboardingDone == true && noticePending) {
|
||||||
|
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
|
||||||
|
}
|
||||||
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
||||||
when (done) {
|
when (done) {
|
||||||
true -> CalendarHost(
|
true -> CalendarHost(
|
||||||
@@ -84,6 +109,10 @@ fun RootScreen(
|
|||||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||||
requestedImportUri = requestedImportUri,
|
requestedImportUri = requestedImportUri,
|
||||||
onImportConsumed = onImportConsumed,
|
onImportConsumed = onImportConsumed,
|
||||||
|
requestedInsertForm = requestedInsertForm,
|
||||||
|
onInsertConsumed = onInsertConsumed,
|
||||||
|
requestedEditKey = requestedEditKey,
|
||||||
|
onEditKeyConsumed = onEditKeyConsumed,
|
||||||
)
|
)
|
||||||
false -> ReminderOnboardingScreen(
|
false -> ReminderOnboardingScreen(
|
||||||
onFinished = reminderOnboarding::finish,
|
onFinished = reminderOnboarding::finish,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.agenda
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import java.time.YearMonth
|
import java.time.YearMonth
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.time.format.FormatStyle
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,10 +86,12 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The concrete span the range covers, starting at [start] through [end]
|
* The concrete span the range covers, starting at [start] through [end]
|
||||||
* (inclusive), for a human-readable header:
|
* (inclusive), for a human-readable header. Both ends use the same day-month
|
||||||
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
|
* 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")
|
* - [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.
|
||||||
*/
|
*/
|
||||||
fun agendaRangeWindowSummary(
|
fun agendaRangeWindowSummary(
|
||||||
range: AgendaRange,
|
range: AgendaRange,
|
||||||
@@ -100,11 +101,15 @@ fun agendaRangeWindowSummary(
|
|||||||
): String {
|
): String {
|
||||||
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
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 javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
||||||
val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
val dayMonth = localizedDateFormatter(locale, "dMMM")
|
||||||
|
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
|
||||||
return when (range) {
|
return when (range) {
|
||||||
AgendaRange.Day -> medium.format(javaStart)
|
AgendaRange.Day -> dayMonthYear.format(javaStart)
|
||||||
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
|
AgendaRange.ThisMonth -> localizedDateFormatter(locale, "LLLLy").format(javaStart)
|
||||||
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} – ${medium.format(javaEnd)}"
|
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
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Coffee
|
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.Menu
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
@@ -45,8 +40,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -59,52 +52,48 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
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.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
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.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
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.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
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.formatTimeOfDay
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Instant
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AgendaScreen(
|
fun AgendaScreen(
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
todayInToolbar: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: AgendaViewModel = hiltViewModel(),
|
viewModel: AgendaViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||||
|
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||||
|
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -147,12 +136,14 @@ fun AgendaScreen(
|
|||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
|
showTodayButton = todayInToolbar,
|
||||||
|
onToday = viewModel::goToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
CalendarFabColumn(
|
CalendarFabColumn(
|
||||||
todayVisible = !isOnToday,
|
todayVisible = !isOnToday && !todayInToolbar,
|
||||||
todayText = stringResource(R.string.agenda_today_action),
|
todayText = stringResource(R.string.agenda_today_action),
|
||||||
onToday = viewModel::goToToday,
|
onToday = viewModel::goToToday,
|
||||||
onCreate = { onCreateEvent(anchor, null) },
|
onCreate = { onCreateEvent(anchor, null) },
|
||||||
@@ -169,9 +160,11 @@ fun AgendaScreen(
|
|||||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.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(
|
AgendaRangeBanner(
|
||||||
range = s.range,
|
range = s.range,
|
||||||
@@ -189,8 +182,10 @@ fun AgendaScreen(
|
|||||||
AgendaContent(
|
AgendaContent(
|
||||||
state = state,
|
state = state,
|
||||||
pastDisplay = pastDisplay,
|
pastDisplay = pastDisplay,
|
||||||
|
showToday = showToday,
|
||||||
onRetry = viewModel::goToToday,
|
onRetry = viewModel::goToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
@@ -204,6 +199,7 @@ fun AgendaScreen(
|
|||||||
title = stringResource(R.string.settings_agenda_range),
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
description = stringResource(R.string.agenda_range_override_hint),
|
description = stringResource(R.string.agenda_range_override_hint),
|
||||||
selected = successState?.range ?: AgendaRange.Month,
|
selected = successState?.range ?: AgendaRange.Month,
|
||||||
|
weekStart = weekStart,
|
||||||
onSelect = viewModel::setRangeOverride,
|
onSelect = viewModel::setRangeOverride,
|
||||||
onDismiss = { showRangePicker = false },
|
onDismiss = { showRangePicker = false },
|
||||||
)
|
)
|
||||||
@@ -211,9 +207,11 @@ fun AgendaScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A compact tonal pill showing the agenda's current range. Tapping it opens the
|
* The agenda's current range, tapped to open the range picker as a session-only
|
||||||
* range picker as a session-only override. Filled with the primary container
|
* override. Shares the top-bar view switcher's button shape so the two read as a
|
||||||
* while an override is active, so the temporary state is obvious.
|
* 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
|
@Composable
|
||||||
private fun AgendaRangePill(
|
private fun AgendaRangePill(
|
||||||
@@ -222,43 +220,34 @@ private fun AgendaRangePill(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val container = if (isOverride) {
|
FilledTonalButton(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
colors = ButtonDefaults.filledTonalButtonColors(
|
||||||
|
containerColor = if (isOverride) {
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
}
|
},
|
||||||
val content = if (isOverride) {
|
contentColor = if (isOverride) {
|
||||||
MaterialTheme.colorScheme.onPrimaryContainer
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
}
|
},
|
||||||
Surface(
|
),
|
||||||
color = container,
|
modifier = modifier,
|
||||||
contentColor = content,
|
|
||||||
shape = RoundedCornerShape(50),
|
|
||||||
modifier = modifier.clickable(onClick = onClick),
|
|
||||||
) {
|
) {
|
||||||
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(
|
||||||
text = agendaRangeLabel(range),
|
text = agendaRangeLabel(range),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A header naming the concrete window currently shown, e.g. "Showing all events
|
* A header naming the concrete window currently shown under a "showing …" label,
|
||||||
* for · Today, 27 Jun 2026" / "This week, 27 Jun – 3 Jul" / "This month, June 2026".
|
* 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
|
@Composable
|
||||||
private fun AgendaRangeBanner(
|
private fun AgendaRangeBanner(
|
||||||
@@ -275,8 +264,10 @@ private fun AgendaRangeBanner(
|
|||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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(
|
||||||
text = "${agendaRangeLabel(range)}, $window",
|
text = window,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
@@ -287,8 +278,10 @@ private fun AgendaRangeBanner(
|
|||||||
private fun AgendaContent(
|
private fun AgendaContent(
|
||||||
state: AgendaUiState,
|
state: AgendaUiState,
|
||||||
pastDisplay: PastEventDisplay,
|
pastDisplay: PastEventDisplay,
|
||||||
|
showToday: Boolean,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
@@ -301,7 +294,7 @@ private fun AgendaContent(
|
|||||||
// Hiding drops finished events — and any day they leave empty; dimming
|
// Hiding drops finished events — and any day they leave empty; dimming
|
||||||
// keeps them but fades the row. Recomputed each minute so events fall
|
// keeps them but fades the row. Recomputed each minute so events fall
|
||||||
// away (or fade) as they end while the screen stays open.
|
// 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 ->
|
state.days.mapNotNull { day ->
|
||||||
val remaining = day.events.filterNot { it.hasEnded(now) }
|
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||||
@@ -309,15 +302,25 @@ private fun AgendaContent(
|
|||||||
} else {
|
} else {
|
||||||
state.days
|
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()) {
|
if (days.isEmpty()) {
|
||||||
AgendaEmpty(modifier)
|
AgendaEmpty(modifier)
|
||||||
} else {
|
} else {
|
||||||
AgendaList(
|
AgendaList(
|
||||||
days = days,
|
days = days,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
|
zone = state.zone,
|
||||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||||
now = now,
|
now = now,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -330,9 +333,11 @@ private fun AgendaContent(
|
|||||||
private fun AgendaList(
|
private fun AgendaList(
|
||||||
days: List<AgendaDay>,
|
days: List<AgendaDay>,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
dimPast: Boolean,
|
dimPast: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -342,72 +347,40 @@ private fun AgendaList(
|
|||||||
) {
|
) {
|
||||||
days.forEach { day ->
|
days.forEach { day ->
|
||||||
stickyHeader(key = "header-${day.date}") {
|
stickyHeader(key = "header-${day.date}") {
|
||||||
AgendaDayHeader(date = day.date, today = today)
|
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
||||||
}
|
}
|
||||||
|
if (day.events.isEmpty()) {
|
||||||
|
// An anchored, event-less today (#35) — "nothing left today".
|
||||||
|
item(key = "placeholder-${day.date}") {
|
||||||
|
AgendaEmptyDayRow(
|
||||||
|
text = stringResource(R.string.agenda_no_more_today),
|
||||||
|
onClick = { onOpenDay(day.date) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = day.events,
|
items = day.events,
|
||||||
key = { _, event -> event.instanceId },
|
// 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 ->
|
) { index, event ->
|
||||||
AgendaEventRow(
|
AgendaEventRow(
|
||||||
event = event,
|
event = event,
|
||||||
|
day = day.date,
|
||||||
|
zone = zone,
|
||||||
position = positionOf(index, day.events.size),
|
position = positionOf(index, day.events.size),
|
||||||
dimmed = dimPast && event.hasEnded(now),
|
dimmed = dimPast && event.hasEnded(now),
|
||||||
modifier = calendarAnimateItem(),
|
modifier = animateItemMotion(),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
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
|
@Composable
|
||||||
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||||
Column(
|
Column(
|
||||||
@@ -437,6 +410,8 @@ private fun AgendaTopBar(
|
|||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
|
showTodayButton: Boolean,
|
||||||
|
onToday: () -> Unit,
|
||||||
scrollBehavior: TopAppBarScrollBehavior,
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -455,6 +430,7 @@ private fun AgendaTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
TodayAction(show = showTodayButton, onToday = onToday)
|
||||||
IconButton(onClick = onOpenSearch) {
|
IconButton(onClick = onOpenSearch) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Search,
|
imageVector = Icons.Default.Search,
|
||||||
@@ -474,42 +450,3 @@ private fun AgendaTopBar(
|
|||||||
scrollBehavior = scrollBehavior,
|
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.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
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.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
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. */
|
/** One calendar day with at least one event, for the agenda list. */
|
||||||
data class AgendaDay(
|
data class AgendaDay(
|
||||||
@@ -15,22 +49,41 @@ data class AgendaDay(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
|
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
|
||||||
* actually carry events). An event that began before [anchor] (ongoing or
|
* actually carry events). A multi-day event surfaces on *every* day it spans,
|
||||||
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a
|
* not just its first — clamped to [[anchor], [windowEnd]] so an event that began
|
||||||
* day, all-day events sort first, then ascending by start time, then title.
|
* 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
|
* Shared by the Agenda screen and the agenda home-screen widget so both group
|
||||||
* and order identically.
|
* and order identically.
|
||||||
*/
|
*/
|
||||||
fun groupAgendaDays(
|
fun groupAgendaDays(
|
||||||
anchor: LocalDate,
|
anchor: LocalDate,
|
||||||
|
windowEnd: LocalDate,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
): List<AgendaDay> =
|
): List<AgendaDay> {
|
||||||
instances
|
val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>()
|
||||||
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) }
|
for (instance in instances) {
|
||||||
.toSortedMap()
|
val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor)
|
||||||
.map { (date, dayEvents) ->
|
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(
|
AgendaDay(
|
||||||
date = date,
|
date = date,
|
||||||
events = dayEvents.sortedWith(
|
events = dayEvents.sortedWith(
|
||||||
@@ -40,6 +93,25 @@ fun groupAgendaDays(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
||||||
@@ -61,5 +133,12 @@ sealed interface AgendaUiState {
|
|||||||
val rangeEnd: LocalDate,
|
val rangeEnd: LocalDate,
|
||||||
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
|
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
|
||||||
val showRangeBar: Boolean,
|
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
|
) : AgendaUiState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
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.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
@@ -20,13 +20,11 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import java.util.Locale
|
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.atTime
|
import kotlinx.datetime.atTime
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
@@ -50,9 +48,12 @@ class AgendaViewModel @Inject constructor(
|
|||||||
settingsPrefs.agendaShowRangeBar,
|
settingsPrefs.agendaShowRangeBar,
|
||||||
) { range, showBar -> AgendaSettings(range, showBar) }
|
) { range, showBar -> AgendaSettings(range, showBar) }
|
||||||
|
|
||||||
// First day of the week, for the calendar-aligned "this week" range.
|
/**
|
||||||
private val weekStartDay = settingsPrefs.weekStart
|
* First day of the week, for the calendar-aligned "this week" range. Public
|
||||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
* 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
|
* How to treat events that already ended today (show / dim / hide). A display
|
||||||
@@ -66,6 +67,19 @@ class AgendaViewModel @Inject constructor(
|
|||||||
initialValue = PastEventDisplay.SHOW,
|
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 zone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
@@ -80,7 +94,7 @@ class AgendaViewModel @Inject constructor(
|
|||||||
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
||||||
|
|
||||||
val state: StateFlow<AgendaUiState> =
|
val state: StateFlow<AgendaUiState> =
|
||||||
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart ->
|
combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart ->
|
||||||
AgendaParams(
|
AgendaParams(
|
||||||
anchor = anchor,
|
anchor = anchor,
|
||||||
range = override ?: settings.range,
|
range = override ?: settings.range,
|
||||||
@@ -146,11 +160,11 @@ class AgendaViewModel @Inject constructor(
|
|||||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
}
|
}
|
||||||
val anchor = params.anchor
|
val anchor = params.anchor
|
||||||
val days = groupAgendaDays(anchor, instances, zone)
|
|
||||||
val rangeEnd = anchor.plus(
|
val rangeEnd = anchor.plus(
|
||||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||||
DateTimeUnit.DAY,
|
DateTimeUnit.DAY,
|
||||||
)
|
)
|
||||||
|
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
|
||||||
return AgendaUiState.Success(
|
return AgendaUiState.Success(
|
||||||
anchor = anchor,
|
anchor = anchor,
|
||||||
today = todayDate,
|
today = todayDate,
|
||||||
@@ -159,6 +173,7 @@ class AgendaViewModel @Inject constructor(
|
|||||||
rangeIsOverride = params.rangeIsOverride,
|
rangeIsOverride = params.rangeIsOverride,
|
||||||
rangeEnd = rangeEnd,
|
rangeEnd = rangeEnd,
|
||||||
showRangeBar = params.showRangeBar,
|
showRangeBar = params.showRangeBar,
|
||||||
|
zone = zone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
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. `.ics` files reach us under several
|
||||||
|
// mimes depending on the source app (our own export uses text/calendar; others
|
||||||
|
// hand them out as octet-stream or text/plain), so accept the common set rather
|
||||||
|
// than hide valid backups behind an over-tight filter.
|
||||||
|
private val RESTORE_MIME_TYPES = arrayOf(
|
||||||
|
"text/calendar",
|
||||||
|
"application/octet-stream",
|
||||||
|
"text/plain",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup & restore (#69). Local calendars aren't synced anywhere, so a `.ics`
|
||||||
|
* export is their only safety net — that made it a data-safety feature hiding in
|
||||||
|
* the calendar manager, where nobody went looking. It now has its own Settings
|
||||||
|
* entry, sharing [CalendarsViewModel] with the manager (both are driven by the
|
||||||
|
* same calendar list).
|
||||||
|
*
|
||||||
|
* 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 the user's own local calendars (managed special-dates
|
||||||
|
// mirrors don't count — they are rebuilt from contacts). Restore can target
|
||||||
|
// any calendar the import picker would offer, so its availability is broader.
|
||||||
|
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||||
|
val canImport = calendars.any { it.isEventTarget }
|
||||||
|
|
||||||
|
// SAF "create document" target for the backup file. The picked Uri is handed
|
||||||
|
// to the VM to stream the .ics into. This launcher 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) }
|
||||||
|
|
||||||
|
// SAF "open document" picker for restoring events from a .ics file. The
|
||||||
|
// picked Uri is handed up to the host, which runs it through the same import
|
||||||
|
// flow as an externally opened .ics (parse, dedup by UID, target picker).
|
||||||
|
val openBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocument(),
|
||||||
|
) { uri -> uri?.let(onImport) }
|
||||||
|
|
||||||
|
// 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(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()) {
|
||||||
|
// One connected card: the one-time export on top, restore under it,
|
||||||
|
// 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 = {
|
||||||
|
// With more than one exportable calendar, let the user choose
|
||||||
|
// which to include; a single one exports straight away.
|
||||||
|
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 (no writable local calendar), but events can
|
||||||
|
// still be restored into a writable calendar — offer restore on its
|
||||||
|
// own so it isn't hidden 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,
|
||||||
|
) {
|
||||||
|
// Seed once with everything selected and hold it across recomposition and
|
||||||
|
// rotation. NOT keyed on [calendars]: the list is observer-driven, so keying
|
||||||
|
// it would silently reset the user's de-selections whenever the provider
|
||||||
|
// re-emits (a background sync, a recolor). Ids that later vanish are harmless
|
||||||
|
// — the data layer intersects the chosen set with the eligible calendars.
|
||||||
|
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,
|
||||||
|
) {
|
||||||
|
// 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
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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` on the first launch
|
||||||
|
* that finds a calendar switched off outside the app — those used to show their
|
||||||
|
* events here and no longer do, and the app deliberately does not switch them
|
||||||
|
* back on, because that would un-hide them in every other calendar app 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.
|
||||||
|
* The launch itself is covered by `CalendulaApp`, but a permission granted
|
||||||
|
* on Android's app-settings screen comes back through `RootScreen`'s
|
||||||
|
* ON_RESUME and never touches the permission screen's callback — so the
|
||||||
|
* trigger hangs off "we are showing the app", not off one grant route.
|
||||||
|
* Settled runs cost two DataStore reads and stop there.
|
||||||
|
*/
|
||||||
|
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.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.provider.Settings
|
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.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.Notes
|
||||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||||
import androidx.compose.material.icons.filled.Add
|
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.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Close
|
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.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.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
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.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -71,43 +65,42 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
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.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.unit.dp
|
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.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
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.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.data.calendar.CalendarColorPalette
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.AccountKey
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
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.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import java.time.LocalDate
|
|
||||||
|
|
||||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||||
@@ -116,19 +109,22 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
|||||||
* Calendar manager (reached from Settings). Lists the app's own device-only
|
* Calendar manager (reached from Settings). Lists the app's own device-only
|
||||||
* calendars with create / rename / recolor / delete (via a full-screen editor),
|
* 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
|
* 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
|
* app" deep-link — the app never touches a synced calendar's server.
|
||||||
* full-screen destination; [onBack] pops it.
|
*
|
||||||
|
* Export/import lives in its own Settings entry ([BackupScreen], #69); this
|
||||||
|
* screen only points at it, because the two are looked for separately: "which
|
||||||
|
* calendars do I have" versus "keep a copy of them". A full-screen destination;
|
||||||
|
* [onBack] pops it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarsScreen(
|
fun CalendarsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onOpenBackup: () -> Unit,
|
||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
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 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.
|
// 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
|
// [editorSession] bumps on every open so the editor's field state resets for
|
||||||
@@ -144,6 +140,7 @@ fun CalendarsScreen(
|
|||||||
initialName = editing?.displayName.orEmpty(),
|
initialName = editing?.displayName.orEmpty(),
|
||||||
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
||||||
initialDescription = editing?.description.orEmpty(),
|
initialDescription = editing?.description.orEmpty(),
|
||||||
|
deleteLocked = editing != null && editing.id in deleteLockedIds,
|
||||||
onSave = { name, color, description ->
|
onSave = { name, color, description ->
|
||||||
val id = editorId
|
val id = editorId
|
||||||
if (id == null || id == NEW_CALENDAR_ID) {
|
if (id == null || id == NEW_CALENDAR_ID) {
|
||||||
@@ -163,21 +160,14 @@ fun CalendarsScreen(
|
|||||||
CalendarsList(
|
CalendarsList(
|
||||||
local = calendars.filter { it.isLocal },
|
local = calendars.filter { it.isLocal },
|
||||||
synced = calendars.filterNot { it.isLocal },
|
synced = calendars.filterNot { it.isLocal },
|
||||||
disabledIds = disabledIds,
|
|
||||||
error = error,
|
error = error,
|
||||||
onConsumeError = viewModel::consumeError,
|
onConsumeError = viewModel::consumeError,
|
||||||
backupResult = backupResult,
|
onOpenBackup = onOpenBackup,
|
||||||
onExportBackup = viewModel::exportBackup,
|
|
||||||
onConsumeBackupResult = viewModel::consumeBackupResult,
|
|
||||||
autoBackup = autoBackup,
|
|
||||||
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
|
|
||||||
onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes,
|
|
||||||
onSetAutoBackupFolder = viewModel::setAutoBackupFolder,
|
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||||
onSetDisabled = viewModel::setDisabled,
|
onSetVisible = viewModel::setCalendarVisible,
|
||||||
onSetAccountDisabled = viewModel::setAccountDisabled,
|
onSetAccountVisible = viewModel::setAccountVisible,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,27 +176,20 @@ fun CalendarsScreen(
|
|||||||
private fun CalendarsList(
|
private fun CalendarsList(
|
||||||
local: List<CalendarSource>,
|
local: List<CalendarSource>,
|
||||||
synced: List<CalendarSource>,
|
synced: List<CalendarSource>,
|
||||||
disabledIds: Set<Long>,
|
|
||||||
error: Boolean,
|
error: Boolean,
|
||||||
onConsumeError: () -> Unit,
|
onConsumeError: () -> Unit,
|
||||||
backupResult: BackupResult?,
|
onOpenBackup: () -> Unit,
|
||||||
onExportBackup: (android.net.Uri) -> Unit,
|
|
||||||
onConsumeBackupResult: () -> Unit,
|
|
||||||
autoBackup: AutoBackupUiState,
|
|
||||||
onSetAutoBackupEnabled: (Boolean) -> Unit,
|
|
||||||
onSetAutoBackupInterval: (Long) -> Unit,
|
|
||||||
onSetAutoBackupFolder: (android.net.Uri) -> Unit,
|
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
onEdit: (CalendarSource) -> Unit,
|
onEdit: (CalendarSource) -> Unit,
|
||||||
onSetDisabled: (Long, Boolean) -> Unit,
|
onSetVisible: (Long, Boolean) -> Unit,
|
||||||
onSetAccountDisabled: (Collection<Long>, Boolean) -> Unit,
|
onSetAccountVisible: (Collection<Long>, Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
// Accounts the user has folded shut; empty = all expanded (keeps every
|
// Accounts the user has folded shut; empty = all expanded (keeps every
|
||||||
// calendar visible by default, the section is collapsible for tidiness).
|
// 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) }
|
var localExpanded by remember { mutableStateOf(true) }
|
||||||
val writeErrorText = stringResource(R.string.calendars_write_error)
|
val writeErrorText = stringResource(R.string.calendars_write_error)
|
||||||
|
|
||||||
@@ -217,50 +200,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(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.calendars_title),
|
title = stringResource(R.string.calendars_title),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
// What the per-calendar / per-account switches below actually do.
|
// 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
|
// Local (device-only) calendars — one collapsible group. The header's
|
||||||
// "+" adds a calendar; the switch enables/disables them all at once;
|
// "+" adds a calendar; the switch enables/disables them all at once;
|
||||||
// tapping a calendar row opens its editor.
|
// 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(
|
CalendarGroup(
|
||||||
title = stringResource(R.string.calendars_local_header),
|
title = stringResource(R.string.calendars_local_header),
|
||||||
expanded = localExpanded,
|
expanded = localExpanded,
|
||||||
@@ -272,17 +224,17 @@ private fun CalendarsList(
|
|||||||
onManage = onAdd,
|
onManage = onAdd,
|
||||||
onToggleExpand = { localExpanded = !localExpanded },
|
onToggleExpand = { localExpanded = !localExpanded },
|
||||||
showToggleAll = local.isNotEmpty(),
|
showToggleAll = local.isNotEmpty(),
|
||||||
allEnabled = local.none { it.id in disabledIds },
|
allEnabled = local.all { it.isVisibleInSystem },
|
||||||
onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) },
|
onToggleAll = { enabled -> onSetAccountVisible(local.map { it.id }, enabled) },
|
||||||
) {
|
) {
|
||||||
if (local.isEmpty()) {
|
if (local.isEmpty()) {
|
||||||
HintText(stringResource(R.string.calendars_local_empty))
|
HintText(stringResource(R.string.calendars_local_empty))
|
||||||
} else {
|
} else {
|
||||||
local.forEachIndexed { index, calendar ->
|
local.forEachIndexed { index, calendar ->
|
||||||
val disabled = calendar.id in disabledIds
|
val disabled = !calendar.isVisibleInSystem
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName,
|
||||||
summary = calendar.description,
|
summary = calendarRowSummary(calendar),
|
||||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
dimmed = disabled,
|
dimmed = disabled,
|
||||||
@@ -291,7 +243,7 @@ private fun CalendarsList(
|
|||||||
EnableSwitch(
|
EnableSwitch(
|
||||||
calendarName = calendar.displayName,
|
calendarName = calendar.displayName,
|
||||||
enabled = !disabled,
|
enabled = !disabled,
|
||||||
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = { onEdit(calendar) },
|
onClick = { onEdit(calendar) },
|
||||||
@@ -300,49 +252,24 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backup — local calendars have no sync, so a .ics export is their only
|
// Backup lives in its own Settings entry now (#69) — this row is the
|
||||||
// safety net. Offered only when there is something to back up.
|
// pointer, so someone looking at their local calendars still finds the
|
||||||
if (local.isNotEmpty()) {
|
// way to keep a copy of them.
|
||||||
Spacer(Modifier.height(16.dp))
|
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(
|
GroupedRow(
|
||||||
title = stringResource(R.string.calendars_backup_action),
|
title = stringResource(R.string.settings_section_backup),
|
||||||
position = Position.Top,
|
summary = stringResource(R.string.settings_backup_subtitle),
|
||||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
position = Position.Alone,
|
||||||
onClick = {
|
leading = { LeadingAvatar(Icons.Default.Backup) },
|
||||||
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 = {
|
trailing = {
|
||||||
Switch(checked = autoBackup.enabled, onCheckedChange = onSetAutoBackupEnabled)
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onClick = { onSetAutoBackupEnabled(!autoBackup.enabled) },
|
onClick = onOpenBackup,
|
||||||
)
|
)
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
@@ -352,14 +279,19 @@ private fun CalendarsList(
|
|||||||
SectionHeader(stringResource(R.string.calendars_synced_header))
|
SectionHeader(stringResource(R.string.calendars_synced_header))
|
||||||
HintText(stringResource(R.string.calendars_synced_hint))
|
HintText(stringResource(R.string.calendars_synced_hint))
|
||||||
synced
|
synced
|
||||||
.groupBy { it.accountName.ifBlank { it.accountType } }
|
.groupByAccount()
|
||||||
.forEach { (account, cals) ->
|
.forEach { group ->
|
||||||
val expanded = account !in collapsedAccounts
|
val cals = group.calendars
|
||||||
val accountType = cals.first().accountType
|
val expanded = group.key !in collapsedAccounts
|
||||||
val accountDisabled = cals.all { it.id in disabledIds }
|
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))
|
Spacer(Modifier.height(16.dp))
|
||||||
CalendarGroup(
|
CalendarGroup(
|
||||||
title = account,
|
title = accountGroupTitle(group),
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
bodyHasRows = true,
|
bodyHasRows = true,
|
||||||
headerDisabled = accountDisabled,
|
headerDisabled = accountDisabled,
|
||||||
@@ -371,29 +303,41 @@ private fun CalendarsList(
|
|||||||
},
|
},
|
||||||
onToggleExpand = {
|
onToggleExpand = {
|
||||||
collapsedAccounts = if (expanded) {
|
collapsedAccounts = if (expanded) {
|
||||||
collapsedAccounts + account
|
collapsedAccounts + group.key
|
||||||
} else {
|
} else {
|
||||||
collapsedAccounts - account
|
collapsedAccounts - group.key
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
showToggleAll = true,
|
showToggleAll = switchable.isNotEmpty(),
|
||||||
allEnabled = cals.none { it.id in disabledIds },
|
allEnabled = switchable.all { it.isVisibleInSystem },
|
||||||
onToggleAll = { enabled -> onSetAccountDisabled(cals.map { it.id }, !enabled) },
|
onToggleAll = { enabled ->
|
||||||
|
onSetAccountVisible(switchable.map { it.id }, enabled)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
cals.forEachIndexed { index, calendar ->
|
// Calendars you can act on first; the ones this device isn't
|
||||||
val disabled = calendar.id in disabledIds
|
// syncing sit at the bottom, dimmed and switchless.
|
||||||
|
val ordered = cals.orderedForManager()
|
||||||
|
ordered.forEachIndexed { index, calendar ->
|
||||||
|
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
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,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
dimmed = disabled,
|
dimmed = disabled,
|
||||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||||
trailing = {
|
trailing = if (calendar.hasVisibilitySwitch) {
|
||||||
|
{
|
||||||
EnableSwitch(
|
EnableSwitch(
|
||||||
calendarName = calendar.displayName,
|
calendarName = calendar.displayName,
|
||||||
enabled = !disabled,
|
enabled = calendar.isVisibleInSystem,
|
||||||
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
onToggle = { enabled ->
|
||||||
|
onSetVisible(calendar.id, enabled)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -401,13 +345,6 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showInterval) {
|
|
||||||
BackupIntervalDialog(
|
|
||||||
currentMinutes = autoBackup.intervalMinutes,
|
|
||||||
onConfirm = onSetAutoBackupInterval,
|
|
||||||
onDismiss = { showInterval = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -421,12 +358,14 @@ private fun CalendarEditor(
|
|||||||
onSave: (name: String, color: Int, description: String?) -> Unit,
|
onSave: (name: String, color: Int, description: String?) -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
|
deleteLocked: Boolean = false,
|
||||||
) {
|
) {
|
||||||
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
|
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
|
||||||
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
||||||
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
||||||
var confirmDelete by remember { mutableStateOf(false) }
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -453,11 +392,23 @@ private fun CalendarEditor(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (!isNew) {
|
if (!isNew) {
|
||||||
IconButton(onClick = { confirmDelete = true }) {
|
// Kept in place while the special-dates sync owns this
|
||||||
|
// calendar, rather than hidden: the button is where you
|
||||||
|
// expect it, disabled, with the card below saying why —
|
||||||
|
// and it comes back to life the moment the feature is
|
||||||
|
// off, when the delete would actually stick.
|
||||||
|
IconButton(
|
||||||
|
onClick = { confirmDelete = true },
|
||||||
|
enabled = !deleteLocked,
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Delete,
|
Icons.Default.Delete,
|
||||||
contentDescription = stringResource(R.string.event_detail_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 +437,20 @@ private fun CalendarEditor(
|
|||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.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(
|
InlineTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
@@ -558,10 +522,33 @@ private fun CalendarEditor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The per-row enable/disable control. Checked = the calendar is shown in the
|
* The row's supporting line: the states that make this calendar behave unlike a
|
||||||
* app; unchecking disables it (events, filters and pickers all drop it) without
|
* plain writable one (#76), then its own description. Text rather than badges —
|
||||||
* touching any provider data. Carries its own content description so the toggle
|
* a row can carry several of these at once next to a switch, which is exactly
|
||||||
* is self-describing to screen readers even on a dimmed row.
|
* what M3 supporting text composes and a row of static chips doesn't.
|
||||||
|
*/
|
||||||
|
@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 `Calendars.VISIBLE`: checked
|
||||||
|
* = the calendar is shown, unchecked = it drops out of every surface (events,
|
||||||
|
* filters, pickers) and the provider stops scheduling its reminders. The flag is
|
||||||
|
* device-local — nothing is deleted and nothing is synced anywhere. Carries its
|
||||||
|
* own content description so the toggle is self-describing to screen readers
|
||||||
|
* even on a dimmed row.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun EnableSwitch(
|
private fun EnableSwitch(
|
||||||
@@ -569,7 +556,7 @@ private fun EnableSwitch(
|
|||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
onToggle: (Boolean) -> Unit,
|
onToggle: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val label = stringResource(R.string.calendars_show_in_app_a11y, calendarName)
|
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
|
||||||
Switch(
|
Switch(
|
||||||
checked = enabled,
|
checked = enabled,
|
||||||
onCheckedChange = onToggle,
|
onCheckedChange = onToggle,
|
||||||
@@ -664,8 +651,8 @@ private fun CalendarGroup(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = expanded,
|
visible = expanded,
|
||||||
enter = calendarExpandEnter(),
|
enter = expandEnter(),
|
||||||
exit = calendarCollapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
Column(content = body)
|
Column(content = body)
|
||||||
}
|
}
|
||||||
@@ -741,175 +728,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
|
@Composable
|
||||||
private fun SectionHeader(text: String) {
|
internal fun SectionHeader(text: String) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
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
|
@Composable
|
||||||
private fun HintText(text: String) {
|
internal fun HintText(text: String) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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
|
* Pick the app to open for managing a synced calendar's account. The account's
|
||||||
@@ -935,9 +780,3 @@ private fun sourceAppIntent(context: Context, accountType: String): Intent {
|
|||||||
return Intent(Settings.ACTION_SYNC_SETTINGS)
|
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.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
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.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.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -45,10 +42,7 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
private val icsExporter: IcsExporter,
|
private val icsExporter: IcsExporter,
|
||||||
private val prefs: CalendarPrefs,
|
|
||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
private val suppressedStore: SuppressedReminderStore,
|
|
||||||
private val notifier: ReminderNotifier,
|
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -62,20 +56,6 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = emptyList(),
|
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. */
|
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||||
settingsPrefs.autoBackupEnabled,
|
settingsPrefs.autoBackupEnabled,
|
||||||
@@ -92,6 +72,33 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = AutoBackupUiState(),
|
initialValue = AutoBackupUiState(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Managed special-dates calendars whose deletion would not stick. While the
|
||||||
|
* feature is on, the sync owns every mirror: it recreates a missing one for
|
||||||
|
* an enabled type on the next pass and deletes the leftover of a disabled
|
||||||
|
* one (`SpecialDatesSyncEngine.reconcileCalendars`), so either way the
|
||||||
|
* delete would appear to work and then undo itself. Turning special dates
|
||||||
|
* off empties this set, and deleting a leftover mirror is a real delete from
|
||||||
|
* then on.
|
||||||
|
*
|
||||||
|
* Read off each calendar's own durable marker ([CalendarSource.isManaged],
|
||||||
|
* the `CAL_SYNC2` one the editor lock already trusts) rather than the stored
|
||||||
|
* ids, which are only rewritten on the next sync pass — a preferences loss
|
||||||
|
* would otherwise unlock a live mirror until then.
|
||||||
|
*/
|
||||||
|
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)
|
private val _error = MutableStateFlow(false)
|
||||||
val error: StateFlow<Boolean> = _error.asStateFlow()
|
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||||
|
|
||||||
@@ -107,11 +114,11 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
|
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
|
||||||
* in [backupResult] for a one-shot message.
|
* in [backupResult] for a one-shot message.
|
||||||
*/
|
*/
|
||||||
fun exportBackup(uri: Uri) {
|
fun exportBackup(uri: Uri, calendarIds: Set<Long>? = null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_backupResult.value = try {
|
_backupResult.value = try {
|
||||||
val count = withContext(io) {
|
val count = withContext(io) {
|
||||||
val events = repository.exportEvents()
|
val events = repository.exportEvents(calendarIds)
|
||||||
icsExporter.writeDocument(
|
icsExporter.writeDocument(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
content = IcsWriter().writeCalendar(events, Clock.System.now()),
|
content = IcsWriter().writeCalendar(events, Clock.System.now()),
|
||||||
@@ -140,54 +147,30 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable or disable a calendar app-side. Disabling removes it from every
|
* Switch a calendar on or off. This is the app's one visibility model: it
|
||||||
* surface but Settings → Calendars (and hides its events) without touching
|
* writes the system's `Calendars.VISIBLE`, so the calendar disappears from
|
||||||
* provider data — purely a reversible Calendula-local view choice.
|
* every surface *and* the provider stops (or resumes) scheduling its
|
||||||
|
* reminders. Nothing is patched by hand — the provider notifies and the
|
||||||
|
* observer re-queries.
|
||||||
|
*
|
||||||
|
* A reminder that came due while the calendar was off stays gone when it is
|
||||||
|
* switched back on. Delivery plans from `Instances` and `Reminders` on every
|
||||||
|
* scan (#75), but the watermark has already moved past that moment, so no
|
||||||
|
* scan returns it again — and on a read-only install the switch never
|
||||||
|
* reaches the provider at all, so nothing even triggers one. Deliberate: the
|
||||||
|
* reminder was silenced on purpose, and the event itself is back in view.
|
||||||
*/
|
*/
|
||||||
fun setDisabled(id: Long, disabled: Boolean) {
|
fun setCalendarVisible(id: Long, visible: Boolean) = write {
|
||||||
viewModelScope.launch {
|
repository.setCalendarsVisible(listOf(id), visible)
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable or disable every calendar of one account in a single write — the
|
* Switch every calendar of one account on or off — the "toggle all"
|
||||||
* "toggle all" affordance on an account header. Done as one set update so the
|
* affordance on an account header. Each row is written on its own, in one
|
||||||
* per-calendar [setDisabled] calls can't race each other.
|
* coroutine so the writes can't race each other.
|
||||||
*/
|
*/
|
||||||
fun setAccountDisabled(ids: Collection<Long>, disabled: Boolean) {
|
fun setAccountVisible(ids: Collection<Long>, visible: Boolean) = write {
|
||||||
viewModelScope.launch {
|
repository.setCalendarsVisible(ids, visible)
|
||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Automatic backup (issue #8) ------------------------------------
|
// --- Automatic backup (issue #8) ------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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 account and a
|
||||||
|
* DAVx5 account can carry the same address and still be two separate accounts,
|
||||||
|
* from separate apps: merging them mixed their calendars into one group whose
|
||||||
|
* header — source logo, "manage in app", toggle-all, collapsed state — was
|
||||||
|
* derived from whichever calendar happened to sort first.
|
||||||
|
*/
|
||||||
|
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] — the same app whose icon
|
||||||
|
* [SourceLogo] draws. Falls back to the raw account type, which is at least
|
||||||
|
* unique, 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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
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
|
* 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
|
* in the calendar's colour — softened to a pastel, or raw when the softener is
|
||||||
* visibility filter so they read identically.
|
* off (issue #36). Shared by the calendar manager and the visibility filter so
|
||||||
|
* they read identically.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(40.dp)
|
.size(40.dp)
|
||||||
@@ -48,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.CalendarMonth,
|
Icons.Filled.CalendarMonth,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = pastelize(color, dark),
|
tint = eventFill(color, dark, soften),
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
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
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
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 calendar that is switched off,
|
||||||
|
* read-only or managed is silently absent — which reads as a missing calendar
|
||||||
|
* rather than an excluded one (#76). [onManageCalendars], when given, adds the
|
||||||
|
* footer row that names the possible reasons and opens the calendar manager,
|
||||||
|
* where each row then says which one applies.
|
||||||
|
*/
|
||||||
|
@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
|
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.ContentTransform
|
||||||
import androidx.compose.animation.EnterTransition
|
import androidx.compose.animation.SizeTransform
|
||||||
import androidx.compose.animation.ExitTransition
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
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.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
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.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the user has asked the system to remove animations (Settings →
|
* Calendar-specific motion. The family's generic content transitions — section
|
||||||
* Accessibility → "Remove animations", which sets the global animator duration
|
* expand/collapse, item reveal, list relayout, cross-fade, predictive-back, and
|
||||||
* scale to 0). Compose animations do *not* honour this platform flag on their
|
* the reduce-motion check — now live in floret-kit's identity module
|
||||||
* own, so the shared motion helpers in this file check it and fall back to a
|
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
|
||||||
* quick cross-fade — opacity only, no spatial movement — to respect the
|
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
|
||||||
* vestibular intent of the setting while keeping state changes legible.
|
* here is only what's specific to paging the calendar grid: the directional
|
||||||
*
|
* month/week/day slide and the specs that feed it.
|
||||||
* Read once at composition; the scale changes rarely and only takes full effect
|
|
||||||
* after a process restart anyway.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
|
||||||
fun rememberReduceMotion(): Boolean {
|
|
||||||
val resolver = LocalContext.current.contentResolver
|
|
||||||
return remember(resolver) {
|
|
||||||
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
* The M3 Expressive spatial spring used for the month/week/day slide: the
|
||||||
* spring-physics spec from the active motion scheme — snappy with a subtle
|
* *default* spring-physics spec from the active motion scheme, rather than a
|
||||||
* springy settle, rather than a fixed easing curve.
|
* 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
|
* Read it in a composable scope (this helper) so it can be captured by the
|
||||||
* non-composable `AnimatedContent` transitionSpec lambda.
|
* non-composable `AnimatedContent` transitionSpec lambda.
|
||||||
@@ -63,27 +38,34 @@ fun rememberReduceMotion(): Boolean {
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The fast effects spec from the active motion scheme, for opacity (fade)
|
* The effects spec from the active motion scheme, for the opacity half of the
|
||||||
* transitions. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
||||||
* for use in non-composable transition lambdas and as the reduced-motion fallback.
|
* 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)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
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
|
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
||||||
* (e.g. "today"); a jump reuses the forward direction.
|
* (e.g. "today"); a jump reuses the forward direction.
|
||||||
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
* @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].
|
* [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(
|
fun calendarSlideTransition(
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
@@ -95,131 +77,26 @@ fun calendarSlideTransition(
|
|||||||
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||||
}
|
}
|
||||||
val dir = if (slideDir == 0) 1 else slideDir
|
val dir = if (slideDir == 0) 1 else slideDir
|
||||||
return slideInHorizontally(spec) { w -> dir * w }
|
return ContentTransform(
|
||||||
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
targetContentEnter =
|
||||||
}
|
slideInHorizontally(spec) { w -> dir * w / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec),
|
||||||
|
initialContentExit =
|
||||||
/**
|
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
|
||||||
* Cross-fade [ContentTransform] for swapping whole screens or content blocks
|
// AnimatedContent clips to the animating container by default, which
|
||||||
* where there is no meaningful spatial direction (e.g. onboarding gates). Pure
|
// shears the pages against the viewport edge as they pass. There is no
|
||||||
* opacity, so it doubles as its own reduced-motion form.
|
// size change here to contain — both pages are the same grid.
|
||||||
*/
|
sizeTransform = SizeTransform(clip = false),
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun calendarFadeThrough(): ContentTransform {
|
|
||||||
val fade = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
|
||||||
return fadeIn(fade).togetherWith(fadeOut(fade))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enter transition for a vertically-revealed section (expandable rows, inline
|
|
||||||
* fields): height grows from the top while fading in. Under reduced motion the
|
|
||||||
* height growth is dropped, leaving a quick fade.
|
|
||||||
*
|
|
||||||
* Pair with [calendarCollapseExit]. This is the promoted form of the pattern
|
|
||||||
* originally inlined in the event edit form, so every expandable surface in the
|
|
||||||
* app reveals the same way.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun calendarExpandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
|
||||||
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
|
||||||
return if (reduceMotion) {
|
|
||||||
fade
|
|
||||||
} else {
|
|
||||||
expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Exit counterpart to [calendarExpandEnter]: shrink + fade, or fade only under reduced motion. */
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun calendarCollapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition {
|
|
||||||
val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec())
|
|
||||||
return if (reduceMotion) {
|
|
||||||
fade
|
|
||||||
} else {
|
|
||||||
shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enter transition for content revealed by an `AnimatedContent`/`AnimatedVisibility`
|
|
||||||
* (e.g. search results once a query resolves): a gentle rise + fade. Reduced
|
|
||||||
* motion keeps the fade only.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun calendarItemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
|
||||||
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
|
||||||
return if (reduceMotion) {
|
|
||||||
fade
|
|
||||||
} else {
|
|
||||||
fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared [LazyItemScope.animateItem] wiring so list rows fade/relocate with the
|
|
||||||
* app's motion scheme instead of Compose's default spring. Returns a bare
|
|
||||||
* [Modifier] under reduced motion so rows snap into place. Requires the list to
|
|
||||||
* supply stable item keys.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun LazyItemScope.calendarAnimateItem(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
|
|
||||||
if (reduceMotion) {
|
|
||||||
Modifier
|
|
||||||
} else {
|
|
||||||
Modifier.animateItem(
|
|
||||||
fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
|
||||||
placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
|
||||||
fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard Android predictive-back transform for a full-screen overlay: as the
|
* How far a page travels, as a fraction of the container width.
|
||||||
* 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
|
* A full width was the obvious reading of "paging", but the two pages are
|
||||||
* once and apply the returned [Modifier] to that screen's root so the preview
|
* stacked and both opaque, so a full-width slide showed one grid racing across
|
||||||
* respects the same back semantics. Under reduced motion the visual preview is
|
* another — the movement carried the whole transition and had a long way to go.
|
||||||
* skipped (the back still works); on API < 34 the system delivers no progress,
|
* Under M3's shared-axis pattern the offset only has to *hint* the direction
|
||||||
* so it degrades to a plain back.
|
* while the cross-fade does the swapping, so a fifth of the width is plenty and
|
||||||
|
* leaves nothing skating past.
|
||||||
*/
|
*/
|
||||||
@Composable
|
private const val SLIDE_TRAVEL_DIVISOR = 5
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wrapping row of round colour swatches; the one matching [selected] is
|
* A wrapping row of round colour swatches; the one matching [selected] is
|
||||||
* ringed and checked. Shared by the calendar editor and the event-colour
|
* ringed and checked. Shared by the calendar editor and the event-colour
|
||||||
* picker so both pick a colour the same way. Swatches render through
|
* 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)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -36,16 +36,18 @@ fun ColorSwatchRow(
|
|||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
colors.forEach { argb ->
|
colors.forEach { argb ->
|
||||||
val isSelected = argb == selected
|
val isSelected = argb == selected
|
||||||
|
val fill = eventFill(argb, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
.size(40.dp)
|
.size(40.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(pastelize(argb, dark))
|
.background(fill)
|
||||||
.then(
|
.then(
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
||||||
@@ -59,7 +61,7 @@ fun ColorSwatchRow(
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Check,
|
Icons.Default.Check,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color.Black.copy(alpha = 0.7f),
|
tint = eventInk(fill, alpha = 0.7f),
|
||||||
modifier = Modifier.size(20.dp),
|
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.Palette
|
||||||
import androidx.compose.material.icons.filled.People
|
import androidx.compose.material.icons.filled.People
|
||||||
import androidx.compose.material.icons.filled.Place
|
import androidx.compose.material.icons.filled.Place
|
||||||
|
import androidx.compose.material.icons.filled.Public
|
||||||
import androidx.compose.material.icons.filled.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
@@ -24,6 +25,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
|
|||||||
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
||||||
EventFormField.Location -> R.string.event_detail_location
|
EventFormField.Location -> R.string.event_detail_location
|
||||||
EventFormField.Description -> R.string.event_detail_description
|
EventFormField.Description -> R.string.event_detail_description
|
||||||
|
EventFormField.Timezone -> R.string.event_detail_timezone
|
||||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||||
EventFormField.Availability -> R.string.event_edit_availability
|
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) {
|
fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||||
EventFormField.Location -> Icons.Default.Place
|
EventFormField.Location -> Icons.Default.Place
|
||||||
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
||||||
|
EventFormField.Timezone -> Icons.Default.Public
|
||||||
EventFormField.Reminders -> Icons.Default.Notifications
|
EventFormField.Reminders -> Icons.Default.Notifications
|
||||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
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
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
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.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
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 androidx.compose.material3.Text
|
||||||
import android.view.WindowManager
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalView
|
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.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.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
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary
|
||||||
/**
|
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||||
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
import kotlinx.datetime.DateTimeUnit
|
||||||
* reuses the app's [CollapsingScaffold] (back button + full width), but with a
|
import kotlinx.datetime.DayOfWeek
|
||||||
* pinned single-line title rather than the large collapsing header — a picker is
|
import kotlinx.datetime.TimeZone
|
||||||
* a short list, so the tall header would only be empty space to scroll past.
|
import kotlinx.datetime.plus
|
||||||
* [content] places the connected grouped rows; selecting one calls [onDismiss].
|
import kotlinx.datetime.toLocalDateTime
|
||||||
*/
|
import kotlin.time.Clock
|
||||||
@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()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||||
@@ -128,20 +53,26 @@ fun <T> OptionPicker(
|
|||||||
* and, for a per-calendar picker ([allowInherit]), "Use default reminder", which
|
* and, for a per-calendar picker ([allowInherit]), "Use default reminder", which
|
||||||
* defers to the global default. Clearing the last checked time reverts to "Use
|
* 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
|
* 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"
|
* 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
|
* 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
|
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
|
||||||
* back.
|
* back.
|
||||||
|
*
|
||||||
|
* [leadTimeSummary] adds a second line to each lead-time row. It exists for the
|
||||||
|
* all-day pickers, where the lead time alone doesn't say when anything happens:
|
||||||
|
* the hour comes from the separate "show all-day reminders at" setting, so
|
||||||
|
* "1 day before" is only half the answer until the row spells out the time.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ReminderDefaultPicker(
|
fun ReminderDefaultPicker(
|
||||||
title: String,
|
title: String,
|
||||||
presets: List<Int>,
|
presets: List<Int>,
|
||||||
selected: CalendarReminderOverride,
|
selected: ReminderOverride,
|
||||||
allowInherit: Boolean,
|
allowInherit: Boolean,
|
||||||
onSelect: (CalendarReminderOverride) -> Unit,
|
onSelect: (ReminderOverride) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
leadTimeSummary: (@Composable (Int) -> String?)? = null,
|
||||||
) {
|
) {
|
||||||
// Optimistic local state: once the user edits, the chosen override is
|
// Optimistic local state: once the user edits, the chosen override is
|
||||||
// authoritative while the picker is open, so quick successive toggles compose
|
// authoritative while the picker is open, so quick successive toggles compose
|
||||||
@@ -158,9 +89,9 @@ fun ReminderDefaultPicker(
|
|||||||
LaunchedEffect(selected) {
|
LaunchedEffect(selected) {
|
||||||
if (!userEdited) current = selected
|
if (!userEdited) current = selected
|
||||||
}
|
}
|
||||||
val inherits = current is CalendarReminderOverride.Inherit
|
val inherits = current is ReminderOverride.Inherit
|
||||||
val isNone = current is CalendarReminderOverride.None
|
val isNone = current is ReminderOverride.None
|
||||||
val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty()
|
||||||
// Custom (non-preset) lead times seen this session, so unchecking one keeps
|
// Custom (non-preset) lead times seen this session, so unchecking one keeps
|
||||||
// its row (unchecked) until the picker closes instead of vanishing mid-tap,
|
// 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.
|
// which would strand a hand-entered value with no way to re-check it.
|
||||||
@@ -173,7 +104,7 @@ fun ReminderDefaultPicker(
|
|||||||
var amountText by rememberSaveable { mutableStateOf("") }
|
var amountText by rememberSaveable { mutableStateOf("") }
|
||||||
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||||
|
|
||||||
fun apply(override: CalendarReminderOverride) {
|
fun apply(override: ReminderOverride) {
|
||||||
userEdited = true
|
userEdited = true
|
||||||
current = override
|
current = override
|
||||||
onSelect(override)
|
onSelect(override)
|
||||||
@@ -197,7 +128,7 @@ fun ReminderDefaultPicker(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
onClick = { apply(CalendarReminderOverride.Inherit) },
|
onClick = { apply(ReminderOverride.Inherit) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -209,7 +140,7 @@ fun ReminderDefaultPicker(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
onClick = { apply(CalendarReminderOverride.None) },
|
onClick = { apply(ReminderOverride.None) },
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
val rowCount = rows.size + 1 // + the custom row
|
val rowCount = rows.size + 1 // + the custom row
|
||||||
@@ -217,6 +148,7 @@ fun ReminderDefaultPicker(
|
|||||||
val checked = minute in selectedMinutes
|
val checked = minute in selectedMinutes
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = reminderLeadTimeLabel(minute),
|
title = reminderLeadTimeLabel(minute),
|
||||||
|
summary = leadTimeSummary?.invoke(minute),
|
||||||
position = positionOf(index, rowCount),
|
position = positionOf(index, rowCount),
|
||||||
selected = checked,
|
selected = checked,
|
||||||
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
|
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
|
||||||
@@ -232,8 +164,8 @@ fun ReminderDefaultPicker(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = customExpanded,
|
visible = customExpanded,
|
||||||
enter = calendarExpandEnter(),
|
enter = expandEnter(),
|
||||||
exit = calendarCollapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
CustomReminderEditor(
|
CustomReminderEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
@@ -250,26 +182,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
|
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
|
||||||
@@ -287,89 +199,51 @@ private fun CustomReminderEditor(
|
|||||||
onConfirm: (Int) -> Unit,
|
onConfirm: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
Surface(
|
CustomAmountEditor(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
amountText = amountText,
|
||||||
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
|
onAmountChange = onAmountChange,
|
||||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
unitLabels = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
|
||||||
modifier = Modifier
|
selectedUnit = unit.ordinal,
|
||||||
.fillMaxWidth()
|
onUnitChange = { onUnitChange(ReminderUnit.entries[it]) },
|
||||||
.padding(horizontal = 16.dp),
|
preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||||
) {
|
|
||||||
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),
|
?: stringResource(R.string.reminder_custom_amount),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
setLabel = stringResource(R.string.reminder_custom_set),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
confirmEnabled = amount != null,
|
||||||
modifier = Modifier.weight(1f),
|
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
|
||||||
enabled = amount != null,
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.reminder_custom_set))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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
|
@Composable
|
||||||
private fun PickerDescription(text: String) {
|
internal fun PickerDescription(text: String) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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
|
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
|
||||||
* options (today / this week / this month) and the rolling windows (next 7 / 30
|
* 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).
|
* days), with a "Custom" row that expands an inline day-count editor (1–365).
|
||||||
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
|
* 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], which
|
||||||
|
* "This week" depends on). Without it the two groups are near-indistinguishable
|
||||||
|
* — "This week" and "Next 7 days" name different windows in the same words, and
|
||||||
|
* only the concrete span tells them apart.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AgendaRangePicker(
|
fun AgendaRangePicker(
|
||||||
title: String,
|
title: String,
|
||||||
description: String,
|
description: String,
|
||||||
selected: AgendaRange,
|
selected: AgendaRange,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
onSelect: (AgendaRange) -> Unit,
|
onSelect: (AgendaRange) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -383,10 +257,21 @@ fun AgendaRangePicker(
|
|||||||
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
|
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 window each option would open right now — 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)
|
||||||
|
}
|
||||||
|
|
||||||
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
||||||
val isSelected = option == selected
|
val isSelected = option == selected
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = agendaRangeLabel(option),
|
title = agendaRangeLabel(option),
|
||||||
|
summary = windowSummary(option),
|
||||||
position = position,
|
position = position,
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
trailing = if (isSelected) {
|
trailing = if (isSelected) {
|
||||||
@@ -421,6 +306,9 @@ fun AgendaRangePicker(
|
|||||||
} else {
|
} else {
|
||||||
stringResource(R.string.agenda_range_custom)
|
stringResource(R.string.agenda_range_custom)
|
||||||
},
|
},
|
||||||
|
// Only a chosen custom window has dates to show; an unset one 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),
|
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
|
||||||
selected = customSelected,
|
selected = customSelected,
|
||||||
trailing = if (customSelected) {
|
trailing = if (customSelected) {
|
||||||
@@ -432,8 +320,8 @@ fun AgendaRangePicker(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = customExpanded,
|
visible = customExpanded,
|
||||||
enter = calendarExpandEnter(),
|
enter = expandEnter(),
|
||||||
exit = calendarCollapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
CustomDaysEditor(
|
CustomDaysEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
@@ -456,41 +344,136 @@ private fun CustomDaysEditor(
|
|||||||
) {
|
) {
|
||||||
val days = amountText.toIntOrNull()
|
val days = amountText.toIntOrNull()
|
||||||
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
|
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
|
||||||
Surface(
|
CustomAmountEditor(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
amountText = amountText,
|
||||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
onAmountChange = onAmountChange,
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
DialogAmountField(
|
|
||||||
value = amountText,
|
|
||||||
onValueChange = onAmountChange,
|
|
||||||
placeholder = "30",
|
placeholder = "30",
|
||||||
)
|
preview = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
Text(
|
|
||||||
text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
|
||||||
?: stringResource(R.string.agenda_range_custom_hint),
|
?: stringResource(R.string.agenda_range_custom_hint),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
setLabel = stringResource(R.string.reminder_custom_set),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
confirmEnabled = days != null,
|
||||||
modifier = Modifier.weight(1f),
|
onConfirm = { days?.let(onConfirm) },
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(16.dp))
|
}
|
||||||
FilledTonalButton(
|
|
||||||
onClick = { days?.let(onConfirm) },
|
/**
|
||||||
enabled = days != null,
|
* 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(),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.reminder_custom_set))
|
CustomSnoozeEditor(
|
||||||
}
|
amountText = amountText,
|
||||||
|
onAmountChange = { amountText = it },
|
||||||
|
unit = unit,
|
||||||
|
onUnitChange = { unit = it },
|
||||||
|
label = label,
|
||||||
|
onConfirm = { minutes ->
|
||||||
|
onSelect(minutes)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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). */
|
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
|
||||||
@Composable
|
@Composable
|
||||||
fun agendaRangeLabel(range: AgendaRange): String = when (range) {
|
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.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import de.jeanlucmakiola.calendula.R
|
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.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.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
@@ -94,6 +103,43 @@ 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.
|
||||||
|
*
|
||||||
|
* The phrase [recurrenceText] renders says what the rule *is*; this says what
|
||||||
|
* it *does*, which is the part a monthly rule on the 31st or an every-other-week
|
||||||
|
* rule with weekday picks gets wrong in people's heads. It expands the rule
|
||||||
|
* with [upcomingOccurrences], so it inherits that function's RFC reading — and
|
||||||
|
* its limits: only the shapes the picker itself can build.
|
||||||
|
*
|
||||||
|
* A rule that yields nothing at all (an end date before the start) says so
|
||||||
|
* rather than showing an empty list, since that is a mistake worth catching
|
||||||
|
* before saving.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun nextOccurrencesText(
|
||||||
|
rule: SimpleRecurrence,
|
||||||
|
// Spelled out: in this file, the bare LocalDate is java.time's.
|
||||||
|
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 — a yearly rule is otherwise the
|
||||||
|
// same date repeated, and a monthly one crossing New Year hides that it did.
|
||||||
|
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. */
|
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
||||||
private fun rruleDayName(token: String, locale: Locale): String? {
|
private fun rruleDayName(token: String, locale: Locale): String? {
|
||||||
val dow = when (token.takeLast(2).uppercase()) {
|
val dow = when (token.takeLast(2).uppercase()) {
|
||||||
@@ -111,16 +157,36 @@ private fun rruleDayName(token: String, locale: Locale): String? {
|
|||||||
|
|
||||||
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
||||||
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
||||||
val digits = raw.takeWhile { it.isDigit() }
|
val date = untilLocalDate(raw) ?: return null
|
||||||
if (digits.length < 8) return null
|
return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
||||||
return try {
|
}
|
||||||
val date = java.time.LocalDate.of(
|
|
||||||
|
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(0, 4).toInt(),
|
||||||
digits.substring(4, 6).toInt(),
|
digits.substring(4, 6).toInt(),
|
||||||
digits.substring(6, 8).toInt(),
|
digits.substring(6, 8).toInt(),
|
||||||
)
|
)
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
}.getOrNull()
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,11 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
|
||||||
/** Common reminder lead times offered as quick picks in the form and settings. */
|
/** Common reminder lead times offered as quick picks in the form and settings. */
|
||||||
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
|
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
|
@StringRes
|
||||||
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
||||||
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,9 @@ import androidx.compose.material3.AlertDialog
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import android.content.Context
|
|
||||||
import android.content.res.Resources
|
|
||||||
import android.provider.Settings
|
|
||||||
import android.text.format.DateFormat
|
|
||||||
import androidx.compose.material3.TimePicker
|
import androidx.compose.material3.TimePicker
|
||||||
import androidx.compose.material3.rememberTimePickerState
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
@@ -27,10 +22,13 @@ fun TimePickerAlert(
|
|||||||
onConfirm: (LocalTime) -> Unit,
|
onConfirm: (LocalTime) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
// Honour the app's own time-format preference (the value every time label
|
||||||
|
// reads), not the system/locale clock — otherwise an explicit 24h setting
|
||||||
|
// still showed an AM/PM dial (issue #27).
|
||||||
val state = rememberTimePickerState(
|
val state = rememberTimePickerState(
|
||||||
initialHour = initial.hour,
|
initialHour = initial.hour,
|
||||||
initialMinute = initial.minute,
|
initialMinute = initial.minute,
|
||||||
is24Hour = deviceUses24HourClock(LocalContext.current),
|
is24Hour = LocalUse24HourFormat.current,
|
||||||
)
|
)
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -45,24 +43,3 @@ fun TimePickerAlert(
|
|||||||
text = { TimePicker(state = state) },
|
text = { TimePicker(state = state) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the clock should read 24-hour, matching the rest of the device.
|
|
||||||
*
|
|
||||||
* [DateFormat.is24HourFormat] resolves a "locale default" system setting against
|
|
||||||
* the *app's* context locale — and this app applies a per-app language
|
|
||||||
* (AppCompatDelegate), so an English UI on a German-region phone would wrongly
|
|
||||||
* read 12-hour while the system clock shows 24-hour. So we honour an explicit
|
|
||||||
* system 12/24 override, and otherwise fall back to the **device** locale
|
|
||||||
* (Resources.getSystem), not the app's.
|
|
||||||
*/
|
|
||||||
private fun deviceUses24HourClock(context: Context): Boolean =
|
|
||||||
when (Settings.System.getString(context.contentResolver, Settings.System.TIME_12_24)) {
|
|
||||||
"24" -> true
|
|
||||||
"12" -> false
|
|
||||||
// 'a' is the AM/PM marker; a best-fit pattern without it is 24-hour.
|
|
||||||
else -> {
|
|
||||||
val deviceLocale = Resources.getSystem().configuration.locales[0]
|
|
||||||
!DateFormat.getBestDateTimePattern(deviceLocale, "jm").contains('a')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
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.
|
* 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.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -43,7 +42,6 @@ import androidx.compose.material3.rememberDrawerState
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -68,8 +66,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
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.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
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.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
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.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
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.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.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.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
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.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
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 java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private val HOUR_HEIGHT = 56.dp
|
private val HOUR_HEIGHT = 56.dp
|
||||||
private val GUTTER_WIDTH = 48.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 MIN_EVENT_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
@@ -119,6 +128,7 @@ fun DayScreen(
|
|||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
todayInToolbar: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialDateIso: String? = null,
|
initialDateIso: String? = null,
|
||||||
viewModel: DayViewModel = hiltViewModel(),
|
viewModel: DayViewModel = hiltViewModel(),
|
||||||
@@ -151,6 +161,13 @@ fun DayScreen(
|
|||||||
else -> true
|
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.
|
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
|
||||||
var slideDir by remember { mutableIntStateOf(0) }
|
var slideDir by remember { mutableIntStateOf(0) }
|
||||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||||
@@ -199,16 +216,19 @@ fun DayScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
DayTopBar(
|
DayTopBar(
|
||||||
date = date,
|
date = date,
|
||||||
|
currentYear = currentYear,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
|
showTodayButton = todayInToolbar,
|
||||||
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
CalendarFabColumn(
|
CalendarFabColumn(
|
||||||
todayVisible = !isOnToday,
|
todayVisible = !isOnToday && !todayInToolbar,
|
||||||
todayText = stringResource(R.string.day_today_action),
|
todayText = stringResource(R.string.day_today_action),
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
onCreate = { onCreateEvent(date, null) },
|
onCreate = { onCreateEvent(date, null) },
|
||||||
@@ -245,8 +265,6 @@ private fun DayContent(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val threshold = with(density) { 24.dp.toPx() }
|
|
||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
@@ -275,20 +293,7 @@ private fun DayContent(
|
|||||||
// Whole-page horizontal swipe, one level above the timeline's vertical
|
// Whole-page horizontal swipe, one level above the timeline's vertical
|
||||||
// scroll: a horizontal drag crosses this detector's slop, while a 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.
|
// drag is consumed by the inner scroll first — the two gestures coexist.
|
||||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||||
detectHorizontalDragGestures(
|
|
||||||
onDragStart = { dragAccum = 0f },
|
|
||||||
onDragEnd = {
|
|
||||||
when {
|
|
||||||
dragAccum < -threshold -> onSwipeNext()
|
|
||||||
dragAccum > threshold -> onSwipePrev()
|
|
||||||
}
|
|
||||||
dragAccum = 0f
|
|
||||||
},
|
|
||||||
onDragCancel = { dragAccum = 0f },
|
|
||||||
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = state,
|
targetState = state,
|
||||||
@@ -354,16 +359,20 @@ private fun DaySuccess(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DayTopBar(
|
private fun DayTopBar(
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
|
currentYear: Int,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
|
showTodayButton: Boolean,
|
||||||
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
|
val locale = currentLocale()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = formatDayTitle(date),
|
text = formatDayTitle(date, locale, currentYear),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -376,6 +385,7 @@ private fun DayTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
TodayAction(show = showTodayButton, onToday = onToday)
|
||||||
IconButton(onClick = onOpenSearch) {
|
IconButton(onClick = onOpenSearch) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Search,
|
imageVector = Icons.Default.Search,
|
||||||
@@ -448,9 +458,11 @@ private fun AllDayBar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
val fill = eventFill(event.color, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
@@ -461,7 +473,7 @@ private fun AllDayBar(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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
|
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||||
// soft corners are permanent at any scroll position.
|
// soft corners are permanent at any scroll position.
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(GUTTER_WIDTH)
|
.width(GUTTER_WIDTH)
|
||||||
|
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState),
|
||||||
) {
|
) {
|
||||||
@@ -610,9 +624,11 @@ private fun EventBlock(
|
|||||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||||
minToHm(block.endMin, use24Hour, locale)
|
minToHm(block.endMin, use24Hour, locale)
|
||||||
val showTime = block.endMin - block.startMin >= 45
|
val showTime = block.endMin - block.startMin >= 45
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
val fill = eventFill(block.event.color, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = "$title, $timeLabel" },
|
.semantics { contentDescription = "$title, $timeLabel" },
|
||||||
@@ -623,7 +639,7 @@ private fun EventBlock(
|
|||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
maxLines = if (showTime) 1 else 2,
|
maxLines = if (showTime) 1 else 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.85f),
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
)
|
)
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
Text(
|
Text(
|
||||||
@@ -631,7 +647,7 @@ private fun EventBlock(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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 =
|
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||||
formatMinuteOfDay(min, is24Hour, locale)
|
formatMinuteOfDay(min, is24Hour, locale)
|
||||||
|
|
||||||
private fun formatDayTitle(date: LocalDate): String {
|
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
|
||||||
val locale = Locale.getDefault()
|
formatCalendarTitle(
|
||||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
|
||||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
locale = locale,
|
||||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
currentYear = currentYear,
|
||||||
return "$weekday, ${date.day}. $monthName ${date.year}"
|
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.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
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.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
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.AttendeeStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
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.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
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.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
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.recurrenceText
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlin.time.Instant
|
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
|
* 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
|
* 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
|
* 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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -126,6 +136,7 @@ fun EventDetailScreen(
|
|||||||
endMillis: Long,
|
endMillis: Long,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onEdit: () -> Unit,
|
onEdit: () -> Unit,
|
||||||
|
onDuplicate: (EventForm) -> Unit,
|
||||||
viewModel: EventDetailViewModel = hiltViewModel(),
|
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(eventId, beginMillis, endMillis) {
|
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||||
@@ -160,15 +171,14 @@ fun EventDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||||
// upgrade in place. Granting continues straight into the tapped action.
|
// upgrade in place. Granting continues straight into the tapped action —
|
||||||
var pendingEdit by remember { mutableStateOf(false) }
|
// edit, delete, or duplicate — held here until the result comes back.
|
||||||
|
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
) { granted ->
|
) { granted ->
|
||||||
if (granted) {
|
if (granted) pendingWrite?.invoke()
|
||||||
if (pendingEdit) onEdit() else showDeleteDialog = true
|
pendingWrite = null
|
||||||
}
|
|
||||||
pendingEdit = false
|
|
||||||
}
|
}
|
||||||
val hasWritePermission = {
|
val hasWritePermission = {
|
||||||
ContextCompat.checkSelfPermission(
|
ContextCompat.checkSelfPermission(
|
||||||
@@ -176,21 +186,20 @@ fun EventDetailScreen(
|
|||||||
Manifest.permission.WRITE_CALENDAR,
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
val onDeleteClick = {
|
val requireWrite: (() -> Unit) -> Unit = { action ->
|
||||||
if (hasWritePermission()) {
|
if (hasWritePermission()) {
|
||||||
showDeleteDialog = true
|
action()
|
||||||
} else {
|
} else {
|
||||||
pendingEdit = false
|
pendingWrite = action
|
||||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val onEditClick = {
|
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
|
||||||
if (hasWritePermission()) {
|
val onEditClick = { requireWrite(onEdit) }
|
||||||
onEdit()
|
// Duplicate reads the loaded detail into a create form and hands it up; the
|
||||||
} else {
|
// create still needs WRITE_CALENDAR even when the source is read-only.
|
||||||
pendingEdit = true
|
val onDuplicateClick = {
|
||||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
requireWrite { viewModel.duplicateForm()?.let(onDuplicate) }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
||||||
@@ -229,7 +238,8 @@ fun EventDetailScreen(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
val s = state
|
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) {
|
if (s is EventDetailUiState.Success) {
|
||||||
IconButton(onClick = onShareClick) {
|
IconButton(onClick = onShareClick) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -237,6 +247,15 @@ fun EventDetailScreen(
|
|||||||
contentDescription = stringResource(R.string.event_detail_share),
|
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,
|
// Edit/delete need a writable calendar — WebCal subscriptions,
|
||||||
// birthday calendars etc. are read-only at the provider level.
|
// 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 instance = detail.instance
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val locale = currentDetailLocale()
|
val locale = currentDetailLocale()
|
||||||
val accent = pastelize(instance.color, dark)
|
val accent = eventFill(instance.color, dark, LocalSoftenColors.current)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
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
|
// Time zone — only when the event is timed and pinned to a zone other
|
||||||
// than the device's, so cross-zone events read unambiguously.
|
// than the device's, so cross-zone events read unambiguously. It answers
|
||||||
foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel ->
|
// "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))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.Default.Public,
|
icon = Icons.Default.Public,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
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)
|
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
* [tz] resolved as a [TimeZoneOption], but only when the event is timed and
|
||||||
* but only when the event is timed and pinned to a zone different from the
|
* pinned to a zone different from the device's — the cases where showing it
|
||||||
* device's. Returns null when there's nothing worth showing.
|
* 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
|
if (isAllDay || tz.isNullOrBlank()) return null
|
||||||
val deviceZone = ZoneId.systemDefault().id
|
if (tz == ZoneId.systemDefault().id) return null
|
||||||
if (tz == deviceZone) return null
|
return timeZoneOptionOf(
|
||||||
return try {
|
tz,
|
||||||
val zone = ZoneId.of(tz)
|
locale,
|
||||||
val name = zone.getDisplayName(JavaTextStyle.FULL, locale)
|
at = at.toJavaInstant(),
|
||||||
if (name == tz) tz else "$name ($tz)"
|
regionOf = ::icuTimeZoneRegion,
|
||||||
} catch (e: Exception) {
|
)
|
||||||
tz
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
|
/** 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.calendar.NoSuchEventException
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
@@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.flowOf
|
|||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -138,16 +141,48 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
}.getOrNull()
|
}.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 {
|
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||||
val detail = repository.eventDetail(target.eventId)
|
val detail = repository.eventDetail(target.eventId)
|
||||||
// The Events row holds the series start; replace it with this
|
// The Events row holds the series start; replace it with this
|
||||||
// occurrence's time so recurring events render correctly.
|
// occurrence's time so recurring events render correctly. An external
|
||||||
val corrected = detail.copy(
|
// "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(
|
instance = detail.instance.copy(
|
||||||
start = Instant.fromEpochMilliseconds(target.beginMillis),
|
start = Instant.fromEpochMilliseconds(target.beginMillis),
|
||||||
end = Instant.fromEpochMilliseconds(target.endMillis),
|
end = Instant.fromEpochMilliseconds(target.endMillis),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
val calendar = repository.calendars().first()
|
val calendar = repository.calendars().first()
|
||||||
.firstOrNull { it.id == corrected.instance.calendarId }
|
.firstOrNull { it.id == corrected.instance.calendarId }
|
||||||
EventDetailUiState.Success(
|
EventDetailUiState.Success(
|
||||||
@@ -168,6 +203,16 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
/** 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)
|
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). */
|
/** 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.
|
* neither list.
|
||||||
*/
|
*/
|
||||||
val hiddenFields: List<EventFormField> = emptyList(),
|
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). */
|
/** True while editing an existing event (the calendar is then fixed). */
|
||||||
val isEditing: Boolean = false,
|
val isEditing: Boolean = false,
|
||||||
/**
|
/**
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user