Compare commits
157 Commits
1b731a4ab0
...
v2.17.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e51e33356 | ||
|
|
e8657117d6 | ||
|
|
d5c53df6b5 | ||
|
|
2f4e05899a | ||
| 06653bf5b1 | |||
| e952393116 | |||
| 384ba39cf9 | |||
|
|
3fca28810b | ||
|
|
c988e1f028 | ||
|
|
a3d136f55d | ||
|
|
db7094c54e | ||
| c70412c782 | |||
| ac0c43f930 | |||
| 314236ac0c | |||
| c6e83fc071 | |||
| 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 | |||
|
|
8cd75716ae | ||
|
|
4feccf2008 |
30
.forgejo/ISSUE_TEMPLATE/bug_report.md
Normal file
30
.forgejo/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Something doesn't work the way it should
|
||||||
|
title: ""
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
---
|
||||||
|
|
||||||
|
### What happened
|
||||||
|
|
||||||
|
|
||||||
|
### What you expected
|
||||||
|
|
||||||
|
|
||||||
|
### Steps to reproduce
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||||
|
- Android version:
|
||||||
|
- Device:
|
||||||
|
- Installed from: <!-- official F-Droid / the self-hosted repo / built from source -->
|
||||||
|
- Affected calendar: <!-- Google, CalDAV (DAVx5, Nextcloud, …), on-device/local,
|
||||||
|
subscribed/WebCal, birthdays — provider behaviour differs
|
||||||
|
a lot per account type, so this often points straight at
|
||||||
|
the cause -->
|
||||||
|
- Time zone: <!-- only if the problem involves dates or all-day events -->
|
||||||
|
|
||||||
18
.forgejo/ISSUE_TEMPLATE/config.yml
Normal file
18
.forgejo/ISSUE_TEMPLATE/config.yml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Kept enabled so anything that doesn't fit the four templates still has a way
|
||||||
|
# in (the `ToDo` label exists for exactly those).
|
||||||
|
blank_issues_enabled: true
|
||||||
|
|
||||||
|
contact_links:
|
||||||
|
- name: Translate Calendula
|
||||||
|
url: https://weblate.dev.jeanlucmakiola.de/engage/calendula/
|
||||||
|
about: >-
|
||||||
|
Translations are managed on Weblate, not here — it owns every values-*
|
||||||
|
file, so a hand-edited translation gets overwritten on the next sync.
|
||||||
|
No coding needed: pick or request a language and translate in the browser.
|
||||||
|
|
||||||
|
- name: Contributing guide
|
||||||
|
url: https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
|
||||||
|
about: >-
|
||||||
|
Before opening a pull request: the issue-first workflow, which release
|
||||||
|
branch to target, how to build (there's a submodule), and the
|
||||||
|
architectural rules a change is reviewed against.
|
||||||
@@ -5,7 +5,7 @@ title: "Crash: "
|
|||||||
labels:
|
labels:
|
||||||
- bug
|
- bug
|
||||||
- crash
|
- crash
|
||||||
- priority:high
|
- priority/high
|
||||||
---
|
---
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
41
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal file
41
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!--
|
||||||
|
Thanks for contributing to Calendula!
|
||||||
|
|
||||||
|
Please skim CONTRIBUTING.md if you haven't:
|
||||||
|
https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
|
||||||
|
|
||||||
|
Two things it's easy to get wrong:
|
||||||
|
• Features need a discussed issue first — an undiscussed feature PR may be
|
||||||
|
closed unmerged even when the code is good.
|
||||||
|
• Target the release branch for your issue's milestone (milestone 2.18.0 →
|
||||||
|
release/v2.18.0), not main. If you targeted main, just say so below and it
|
||||||
|
will be retargeted.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### What this changes
|
||||||
|
|
||||||
|
|
||||||
|
### Why
|
||||||
|
|
||||||
|
<!-- Closes #123 — link the issue this implements or fixes. -->
|
||||||
|
|
||||||
|
|
||||||
|
### How it was tested
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Which of these ran green, and anything you exercised by hand. On-device notes
|
||||||
|
are especially useful for UI changes.
|
||||||
|
|
||||||
|
./gradlew lint test assembleDebug
|
||||||
|
python3 scripts/check_translations.py
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] There's an issue for this, and (for a feature) it got a go-ahead
|
||||||
|
- [ ] Targeting the release branch for that issue's milestone — or `main`, noted above
|
||||||
|
- [ ] `./gradlew lint test assembleDebug` passes locally
|
||||||
|
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
|
||||||
|
- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible
|
||||||
|
- [ ] No planning or design documents committed
|
||||||
@@ -37,14 +37,29 @@ jobs:
|
|||||||
- name: Reproducible-release invariant
|
- name: Reproducible-release invariant
|
||||||
run: bash scripts/check_reproducible_release.sh
|
run: bash scripts/check_reproducible_release.sh
|
||||||
|
|
||||||
# Decide whether anything that affects the app build changed. Docs,
|
# Decide whether anything that affects the app build changed. Docs, store
|
||||||
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
|
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
||||||
# Gradle work below but still report a green `ci`.
|
# the SDK + Gradle work below but still report a green `ci`.
|
||||||
- name: Classify change scope
|
- name: Classify change scope
|
||||||
id: scope
|
id: scope
|
||||||
|
env:
|
||||||
|
# Deliberately a skip-list, not a build-list: a path nobody thought
|
||||||
|
# about defaults to building. Only paths the Gradle build provably
|
||||||
|
# never reads belong here — note that the workflows themselves, the
|
||||||
|
# `.gitmodules` submodule pointer and `scripts/` are *not* in it.
|
||||||
|
SKIP_RE: '(\.md$|^docs/|^fastlane/|^fdroid-metadata/|^licenses/|^\.planning/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^renovate\.json5$|^LICENSE$)'
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
BASE="${{ github.base_ref }}"
|
BASE="${{ github.base_ref }}"
|
||||||
|
# Normally the bare branch name; tolerate a full ref, which would
|
||||||
|
# otherwise make the merge-base lookup fail and quietly degrade this
|
||||||
|
# guard into "always build".
|
||||||
|
BASE="${BASE#refs/heads/}"
|
||||||
|
if [ -z "$BASE" ]; then
|
||||||
|
echo "No base branch on this event — running the full build to be safe."
|
||||||
|
echo "code=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
# Full (not --depth=1) base fetch so the merge-base is present even when
|
# Full (not --depth=1) base fetch so the merge-base is present even when
|
||||||
# the PR branch forked several commits back; a shallow tip has no merge
|
# the PR branch forked several commits back; a shallow tip has no merge
|
||||||
# base with a divergent branch and `git diff base...HEAD` aborts.
|
# base with a divergent branch and `git diff base...HEAD` aborts.
|
||||||
@@ -58,11 +73,15 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
CHANGED=$(git diff --name-only "$MB" HEAD)
|
CHANGED=$(git diff --name-only "$MB" HEAD)
|
||||||
echo "Changed files:"; echo "$CHANGED"
|
echo "Changed files:"; echo "$CHANGED"
|
||||||
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then
|
RELEVANT=$(echo "$CHANGED" | grep -vE "$SKIP_RE" || true)
|
||||||
|
if [ -n "$RELEVANT" ]; then
|
||||||
|
# Naming them makes "why did my docs PR build for four minutes?"
|
||||||
|
# answerable from the log alone.
|
||||||
|
echo "Build-relevant changes:"; echo "$RELEVANT"
|
||||||
echo "code=true" >> "$GITHUB_OUTPUT"
|
echo "code=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "code=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Docs/metadata-only change — skipping the Android build."
|
echo "Docs/metadata-only change — skipping the Android build."
|
||||||
|
echo "code=false" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
@@ -72,9 +91,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.
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Something doesn't work the way it should
|
|
||||||
title: ""
|
|
||||||
labels:
|
|
||||||
- bug
|
|
||||||
---
|
|
||||||
|
|
||||||
### What happened
|
|
||||||
|
|
||||||
|
|
||||||
### What you expected
|
|
||||||
|
|
||||||
|
|
||||||
### Steps to reproduce
|
|
||||||
1.
|
|
||||||
2.
|
|
||||||
3.
|
|
||||||
|
|
||||||
### Environment
|
|
||||||
- Calendula version: <!-- Settings → bottom of the screen -->
|
|
||||||
- Android version:
|
|
||||||
- Device:
|
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
name: Release — F-Droid repo + Gitea release
|
name: Release — F-Droid repo + Gitea/Codeberg release + Play
|
||||||
|
|
||||||
# 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 trailing `play` job then uploads the App Bundle to Google Play. It is last
|
||||||
|
# and separate because Play is the only channel that can reject a good build for
|
||||||
|
# reasons the pipeline can't see, and that must not endanger a release which has
|
||||||
|
# already shipped to F-Droid and Codeberg. It skips cleanly until the
|
||||||
|
# PLAY_SERVICE_ACCOUNT_JSON secret exists.
|
||||||
#
|
#
|
||||||
# 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 +33,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 }}
|
||||||
@@ -39,8 +55,16 @@ jobs:
|
|||||||
- 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)
|
||||||
@@ -58,15 +82,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 —
|
||||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
# republishing to F-Droid and Play. Failing here is recoverable; a
|
||||||
else
|
# duplicate release is not.
|
||||||
echo "No tag for v$VERSION yet — cutting the release."
|
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
|
||||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
case "$STATUS" in
|
||||||
fi
|
200)
|
||||||
|
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
|
||||||
|
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||||
|
;;
|
||||||
|
404)
|
||||||
|
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
|
||||||
|
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
|
||||||
|
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
# 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
|
||||||
@@ -350,3 +387,264 @@ 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."
|
||||||
|
|
||||||
|
# Play takes an App Bundle, not the APK, so it is a second artifact from
|
||||||
|
# the same source and the same signing config — not a repackage of the
|
||||||
|
# APK. The release key signs it, but Play only ever treats that key as the
|
||||||
|
# *upload* key: Play App Signing re-signs with Google's own key before
|
||||||
|
# delivery. A Play install and an F-Droid install therefore carry
|
||||||
|
# different signatures and cannot update each other. That divergence is a
|
||||||
|
# deliberate, documented choice (docs/RELEASING.md), not an accident.
|
||||||
|
#
|
||||||
|
# Built LAST and `continue-on-error`, both deliberately: everything above
|
||||||
|
# has already shipped by this point, and nothing Play-related may put that
|
||||||
|
# at risk. Sitting mid-job without continue-on-error, this block took the
|
||||||
|
# whole 2.17.0 release down with it — no F-Droid publish, no tag, no
|
||||||
|
# Codeberg mirror — over an artifact upload. A failure here now costs the
|
||||||
|
# Play upload and nothing else.
|
||||||
|
#
|
||||||
|
# Nothing here touches the F-Droid path: the AAB is never copied into the
|
||||||
|
# repo, never attached to a release, and its build cannot change the APK
|
||||||
|
# published above.
|
||||||
|
#
|
||||||
|
# AGP embeds the R8 mapping in the bundle's BUNDLE-METADATA, so Play gets
|
||||||
|
# deobfuscated stacktraces without a separate mapping upload.
|
||||||
|
- name: Build release AAB
|
||||||
|
if: env.IS_RELEASE == 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
run: ./gradlew bundleRelease
|
||||||
|
|
||||||
|
# NOT actions/upload-artifact@v4: it runs @actions/artifact v2, which
|
||||||
|
# refuses to start whenever GITHUB_SERVER_URL is not github.com — it reads
|
||||||
|
# any other forge as an unsupported GHES instance and fails before it ever
|
||||||
|
# talks to the server (go-gitea/gitea#36024). Gitea 1.25 serves the v4
|
||||||
|
# artifact API fine; only the client-side check is wrong. This fork is that
|
||||||
|
# client with the check removed. Pinned to a commit, not the v4 branch: a
|
||||||
|
# third-party action in the signing pipeline must not change under us.
|
||||||
|
- name: Hand the AAB to the Play job
|
||||||
|
if: env.IS_RELEASE == 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
|
||||||
|
with:
|
||||||
|
name: release-aab-${{ needs.detect.outputs.version }}
|
||||||
|
path: app/build/outputs/bundle/release/app-release.aab
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
# Google Play channel.
|
||||||
|
#
|
||||||
|
# A separate job, on purpose, running only AFTER the F-Droid publish and both
|
||||||
|
# forge releases have completed. Play is the one channel that can reject a
|
||||||
|
# perfectly good build for reasons outside the pipeline (listing rules, policy
|
||||||
|
# review, API outage, a track that needs manual promotion). Isolating it means
|
||||||
|
# such a rejection surfaces as one red job next to a release that already
|
||||||
|
# shipped everywhere else, instead of failing the workflow that publishes it.
|
||||||
|
#
|
||||||
|
# Not a `container:` job even though a fastlane image exists: act_runner does
|
||||||
|
# not provide node inside custom job containers, so JavaScript actions
|
||||||
|
# (checkout, download-artifact) can't run there. The Renovate job gets away
|
||||||
|
# with a container because its only step is a shell command. Ruby is installed
|
||||||
|
# the same way sshpass, jq and fdroidserver are in the job above.
|
||||||
|
play:
|
||||||
|
needs: [detect, release]
|
||||||
|
# workflow_dispatch is the F-Droid re-sign recovery path — it must never
|
||||||
|
# touch Play, so gate on a real release only.
|
||||||
|
if: needs.detect.outputs.is_release == 'true'
|
||||||
|
runs-on: docker
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.detect.outputs.version }}
|
||||||
|
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
|
||||||
|
# Where the bundle lands. `internal` by default so a release reaches
|
||||||
|
# testers rather than the public, and promotion to production stays a
|
||||||
|
# deliberate human action in the Play Console — the same posture as
|
||||||
|
# holding UI releases for on-device review. Override with the PLAY_TRACK
|
||||||
|
# repo variable once the flow is trusted.
|
||||||
|
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'internal' }}
|
||||||
|
PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }}
|
||||||
|
# Set PLAY_DRY_RUN=true to validate the edit against the API and discard
|
||||||
|
# it instead of committing — used to rehearse the first upload.
|
||||||
|
PLAY_DRY_RUN: ${{ vars.PLAY_DRY_RUN || 'false' }}
|
||||||
|
BUNDLE_PATH: vendor/bundle
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Skip cleanly (not fatally) when Play isn't configured yet, so the rest
|
||||||
|
# of the release pipeline keeps working during setup — same contract as
|
||||||
|
# the Codeberg mirror step.
|
||||||
|
- name: Write the Play service-account key
|
||||||
|
id: key
|
||||||
|
env:
|
||||||
|
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${PLAY_SERVICE_ACCOUNT_JSON:-}" ]; then
|
||||||
|
echo "PLAY_SERVICE_ACCOUNT_JSON not set — skipping the Play upload."
|
||||||
|
echo "configured=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
|
||||||
|
# Fail here, with a clear message, rather than inside fastlane: a
|
||||||
|
# mangled multi-line secret is the likeliest setup mistake.
|
||||||
|
python3 -c "import json,sys; d=json.load(open('play-service-account.json')); sys.exit(0 if d.get('type')=='service_account' else 1)" \
|
||||||
|
|| { echo "PLAY_SERVICE_ACCOUNT_JSON is not a valid service-account JSON." >&2; exit 1; }
|
||||||
|
echo "configured=true" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Same GHES-detection problem as the upload side, same fix — see the
|
||||||
|
# handoff step in the release job.
|
||||||
|
- name: Download the AAB
|
||||||
|
if: steps.key.outputs.configured == 'true'
|
||||||
|
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 # v4
|
||||||
|
with:
|
||||||
|
name: release-aab-${{ needs.detect.outputs.version }}
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Install Ruby
|
||||||
|
if: steps.key.outputs.configured == 'true'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SUDO=""
|
||||||
|
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
|
||||||
|
$SUDO apt-get update
|
||||||
|
# ruby-dev + build-essential: several of fastlane's dependencies build
|
||||||
|
# native extensions.
|
||||||
|
$SUDO apt-get install -y ruby-full ruby-dev build-essential
|
||||||
|
ruby -v
|
||||||
|
|
||||||
|
# Only the first release pays the full gem build; afterwards this restores.
|
||||||
|
- name: Cache bundled gems
|
||||||
|
if: steps.key.outputs.configured == 'true'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: vendor/bundle
|
||||||
|
key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-gems-
|
||||||
|
|
||||||
|
- name: Install fastlane
|
||||||
|
if: steps.key.outputs.configured == 'true'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
gem install bundler --no-document
|
||||||
|
bundle config set --local path vendor/bundle
|
||||||
|
bundle install --jobs 4
|
||||||
|
bundle exec fastlane --version
|
||||||
|
|
||||||
|
- name: Upload to Play
|
||||||
|
if: steps.key.outputs.configured == 'true'
|
||||||
|
env:
|
||||||
|
SUPPLY_JSON_KEY: play-service-account.json
|
||||||
|
# supply is chatty on a TTY-less runner otherwise.
|
||||||
|
FASTLANE_SKIP_UPDATE_CHECK: '1'
|
||||||
|
FASTLANE_HIDE_CHANGELOG: '1'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -f "dist/app-release.aab"
|
||||||
|
bundle exec fastlane deploy \
|
||||||
|
aab:"dist/app-release.aab" \
|
||||||
|
track:"$PLAY_TRACK" \
|
||||||
|
release_status:"$PLAY_RELEASE_STATUS" \
|
||||||
|
dry_run:"$PLAY_DRY_RUN"
|
||||||
|
echo "Uploaded $VERSION (code $VERSION_CODE) to the '$PLAY_TRACK' track."
|
||||||
|
|
||||||
|
# The workspace is reused between runs on a self-hosted runner, so the
|
||||||
|
# credential must not outlive the job.
|
||||||
|
- name: Shred the service-account key
|
||||||
|
if: always()
|
||||||
|
run: shred -u play-service-account.json 2>/dev/null || rm -f play-service-account.json
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -50,11 +50,27 @@ google-services.json
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor swap/backup files
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
# F-Droid local artifacts (the pipeline generates them in CI)
|
# F-Droid local artifacts (the pipeline generates them in CI)
|
||||||
/fdroid/
|
/fdroid/
|
||||||
|
|
||||||
# KSP
|
# KSP
|
||||||
.ksp/
|
.ksp/
|
||||||
|
|
||||||
|
# Google Play Developer API service-account key. Reconstructed in CI from the
|
||||||
|
# PLAY_SERVICE_ACCOUNT_JSON secret and shredded afterwards — never committed.
|
||||||
|
/play-service-account.json
|
||||||
|
|
||||||
|
# fastlane (Play uploader only — see fastlane/Fastfile)
|
||||||
|
/fastlane/report.xml
|
||||||
|
/fastlane/README.md
|
||||||
|
/vendor/bundle/
|
||||||
|
/.bundle/
|
||||||
|
Gemfile.lock
|
||||||
|
|
||||||
# Claude Code
|
# Claude Code
|
||||||
/CLAUDE.md
|
/CLAUDE.md
|
||||||
|
|||||||
2
.gitmodules
vendored
2
.gitmodules
vendored
@@ -1,3 +1,3 @@
|
|||||||
[submodule "floret-kit"]
|
[submodule "floret-kit"]
|
||||||
path = floret-kit
|
path = floret-kit
|
||||||
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git
|
url = https://codeberg.org/jlmakiola/floret-kit.git
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ re-inventing the calendar sync stack — leave that to DAVx5 and the system.
|
|||||||
## Current Milestone
|
## Current Milestone
|
||||||
|
|
||||||
Milestones 1 (read, v1.0) and 2 (write support, v1.1–v2.0.0 incl. reminder
|
Milestones 1 (read, v1.0) and 2 (write support, v1.1–v2.0.0 incl. reminder
|
||||||
delivery) are **complete** — v2.0.0 shipped 2026-06-11. Next is v3.0
|
delivery) are **complete** — v2.0.0 shipped 2026-06-11. Everything since is
|
||||||
(power-user features) plus an undecided "Locations & People" idea backlog;
|
tracked as issues and milestones on Codeberg:
|
||||||
see `ROADMAP.md`.
|
<https://codeberg.org/jlmakiola/calendula/milestones>. A milestone maps to its
|
||||||
|
`release/vX.Y.Z` branch.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -27,10 +28,26 @@ Kotlin 2.3.21 (paired with KSP 2.3.9 — Kotlin 2.4.0 has no KSP release
|
|||||||
yet, do not upgrade until one ships). Jetpack Compose + Material 3
|
yet, do not upgrade until one ships). Jetpack Compose + Material 3
|
||||||
Expressive 1.5.0-alpha21 (alpha is intentional — Expressive APIs only
|
Expressive 1.5.0-alpha21 (alpha is intentional — Expressive APIs only
|
||||||
live in the 1.5 alpha line). Hilt 2.59.2, DataStore. Gradle Kotlin DSL
|
live in the 1.5 alpha line). Hilt 2.59.2, DataStore. Gradle Kotlin DSL
|
||||||
with Version Catalog. AGP 9.1.1, Gradle 9.5.1. JVM target 17.
|
with Version Catalog. AGP 9.2.1, Gradle 9.5.1. JVM target 17 (exactly — AGP
|
||||||
|
requires it).
|
||||||
|
|
||||||
Android-only (minSdk 29, targetSdk 36). No iOS. No `INTERNET` permission —
|
The shared Material 3 Expressive kit, **floret-kit**, is a git submodule wired
|
||||||
any feature that would need one is an explicit product decision first.
|
in as a Gradle composite build, so it compiles from source rather than resolving
|
||||||
|
as a dependency.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **Platform:** Android-only, Android 10+ (minSdk 29), targetSdk 36. No iOS.
|
||||||
|
- **Offline-first:** all data lives in `CalendarContract` — no app database, no
|
||||||
|
sync stack. No `INTERNET` permission; any feature needing one is an explicit
|
||||||
|
product decision first.
|
||||||
|
- **Privacy:** zero telemetry, zero analytics.
|
||||||
|
- **i18n:** German + English from day one; further languages come from community
|
||||||
|
translators via the self-hosted Weblate, which owns every `values-*` file.
|
||||||
|
- **Tests + CI from day one**, JVM-first.
|
||||||
|
- **Reproducible release builds**, so the official F-Droid repo can verify the
|
||||||
|
published binary against a from-source rebuild.
|
||||||
|
- **Licence:** MIT.
|
||||||
|
|
||||||
## Naming
|
## Naming
|
||||||
|
|
||||||
@@ -40,5 +57,9 @@ shows a stylized "1" on a slate squircle.
|
|||||||
|
|
||||||
## Source
|
## Source
|
||||||
|
|
||||||
Hosted on self-hosted Gitea, released through self-hosted F-Droid repo on
|
**Codeberg (`jlmakiola/calendula`) is canonical** — git, issues, PRs, tags and
|
||||||
Hetzner. Same infrastructure as `HouseHoldKeaper`.
|
releases, plus contributor CI. The self-hosted Gitea instance is build
|
||||||
|
infrastructure only: it holds the signing key, runs the release pipeline, and
|
||||||
|
publishes the self-hosted F-Droid repo on Hetzner. Codeberg push-mirrors `main`
|
||||||
|
and tags to Gitea, where a bumped `versionName` triggers the release. Also
|
||||||
|
published to the official F-Droid repo. See `docs/RELEASING.md`.
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
# Calendula — Requirements
|
|
||||||
|
|
||||||
See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
|
|
||||||
|
|
||||||
## V1 Scope (Variant "B") — shipped in full (v1.0.0, 2026-06-11)
|
|
||||||
|
|
||||||
- [x] Foundation & CI infrastructure — v0.1.0 (2026-06-08)
|
|
||||||
- [x] Data Layer over `CalendarContract`
|
|
||||||
- [x] Permission flow (`READ_CALENDAR`)
|
|
||||||
- [x] Month view (S1)
|
|
||||||
- [x] Week view (S2)
|
|
||||||
- [x] Day view (S3)
|
|
||||||
- [x] Event Detail Sheet (S4) — became a full screen, plus full event read (v0.6)
|
|
||||||
- [x] Multi-Calendar Filter (M3)
|
|
||||||
- [x] Today button (M2) — shipped v0.5; Jump-to-Date **cut from scope**
|
|
||||||
- [x] View-Switcher (M1)
|
|
||||||
- [x] Settings screen (M4)
|
|
||||||
- [x] Empty / no-permission / no-calendars states
|
|
||||||
- [x] German + English localization
|
|
||||||
- [x] Loading/Failure/Success states per screen (architectural pattern)
|
|
||||||
|
|
||||||
## V2 Scope — write support, shipped in full (v2.0.0, 2026-06-11)
|
|
||||||
|
|
||||||
- [x] Write foundation: `WRITE_CALENDAR`, read-only-calendar detection, delete (v1.1)
|
|
||||||
- [x] Create event: form, FAB, last-used calendar (v1.2; polish v1.2.1)
|
|
||||||
- [x] Edit event: shared form, scoped recurring writes, recurrence picker (v1.3)
|
|
||||||
- [x] Reminder notifications (v1.4) — **reversal of the original
|
|
||||||
"system handles reminders" assumption:** Calendula targets
|
|
||||||
sole-calendar-app users, so it posts reminder notifications itself
|
|
||||||
(Etar model), incl. `POST_NOTIFICATIONS` onboarding
|
|
||||||
- [x] Conflict dialog on save + store polish (v2.0)
|
|
||||||
- Quick-add — **cut from scope** (the prefilled form covers it)
|
|
||||||
- Calendar switching while editing — moved to v3 backlog
|
|
||||||
|
|
||||||
### Out of Scope (V3+)
|
|
||||||
|
|
||||||
- Home-screen widget
|
|
||||||
- Full-text search
|
|
||||||
- Tablet/foldable-specific layouts
|
|
||||||
- Locations & People ideas (contact picker, OSM autocomplete) — see
|
|
||||||
`ROADMAP.md` idea backlog, undecided
|
|
||||||
- iOS support (Android-only by design)
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- **Tech stack:** Kotlin + Jetpack Compose + Material 3 Expressive, Hilt, DataStore
|
|
||||||
- **Tech stack pin:** Hilt 2.59.2 + KSP 2.3.9; Kotlin 2.3.21 (KSP for Kotlin 2.4.0 not released yet). Material 3 pinned to `1.5.0-alpha21` (Expressive APIs only exist in alpha). Re-evaluate when KSP/Material3 stable land.
|
|
||||||
- **Platform:** Android 10+ (API 29 minimum), Android 16 (API 36) target
|
|
||||||
- **Offline-first:** all data lives in `CalendarContract`; no app-side network
|
|
||||||
- **Privacy:** zero telemetry, no analytics
|
|
||||||
- **i18n:** German + English from day one
|
|
||||||
- **Tests + CI from day one**
|
|
||||||
- **License:** MIT
|
|
||||||
@@ -1,587 +0,0 @@
|
|||||||
# Calendula — Roadmap
|
|
||||||
|
|
||||||
## v0.x — Pre-Release
|
|
||||||
|
|
||||||
| Version | Milestone | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| v0.1 | Foundation & CI | complete |
|
|
||||||
| v0.2 | Data Layer & Permission Flow | complete |
|
|
||||||
| v0.3 | Month + Week + Day views, view switcher | complete |
|
|
||||||
| v0.4 | Event Detail (S4) + humanized recurrence | complete |
|
|
||||||
| v0.5 | Calendar filter (M3) + Settings (M4) | complete |
|
|
||||||
| v0.6 | Full event read — surface every readable field | complete |
|
|
||||||
| v1.0 | First public release — polish pass, F-Droid | complete |
|
|
||||||
|
|
||||||
Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and
|
|
||||||
Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5.
|
|
||||||
|
|
||||||
Jump-to-date (the date-picker half of M2) was **cut from scope** and will not
|
|
||||||
ship. The "Today" half of M2 already shipped in v0.5 (drawer entry).
|
|
||||||
|
|
||||||
## v0.6 — Full event read
|
|
||||||
|
|
||||||
Round out the read-only model so a detail view shows everything the system
|
|
||||||
actually stores, before write support starts. Scope = `CalendarContract`
|
|
||||||
columns we don't yet read/display:
|
|
||||||
|
|
||||||
- **Reminders** (`VALARM`) — read `CalendarContract.Reminders`, list lead times
|
|
||||||
- **Status** — Confirmed / Tentative / Cancelled (cancelled shown struck-through)
|
|
||||||
- **Availability** (`TRANSP`) — Free / Busy chip
|
|
||||||
- **Attendee extras** — role (required / optional / organizer) + the user's own
|
|
||||||
`SELF_ATTENDEE_STATUS`
|
|
||||||
- **Timezone** (`EVENT_TIMEZONE`) — shown only when it differs from the device zone
|
|
||||||
- **URL** — ~~tappable link card~~ **cut**: `CalendarContract` exposes no
|
|
||||||
`Events.URL` column (only `CUSTOM_APP_URI`, an originating-app deep-link).
|
|
||||||
URLs are instead surfaced by linkifying the description text
|
|
||||||
- **Access level / class** (private / confidential) — small chip (optional, trivial)
|
|
||||||
|
|
||||||
All of the above shipped in v0.6.0 (2026-06-11).
|
|
||||||
|
|
||||||
Deliberately out of v0.6:
|
|
||||||
- Recurrence exception / modified-occurrence badges — `Instances` already
|
|
||||||
resolves correct per-occurrence times for display; this only matters for
|
|
||||||
editing, so it folds into v2
|
|
||||||
- `CATEGORIES`, `ATTACH` — not reliably exposed by `CalendarContract`
|
|
||||||
(provider limitation, not our choice)
|
|
||||||
|
|
||||||
## v1.0 — First Public Release — shipped 2026-06-11
|
|
||||||
|
|
||||||
All V1 features shipped, polished, on F-Droid. Read-only calendar. Cut directly
|
|
||||||
after v0.6 (full event read) plus the onboarding-screen polish pass.
|
|
||||||
|
|
||||||
### Polish backlog (pre-1.0)
|
|
||||||
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
|
|
||||||
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
|
|
||||||
|
|
||||||
## v2.0 — Write Support (complete, shipped 2026-06-11)
|
|
||||||
|
|
||||||
Delivered in four releasable slices (plan:
|
|
||||||
`docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a
|
|
||||||
guide here, not a contract — scope per slice is decided as we go.
|
|
||||||
|
|
||||||
| Version | Milestone | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| v1.1 | Write foundation — `WRITE_CALENDAR`, read-only-calendar detection, delete (series + single occurrence) | complete (shipped 2026-06-11) |
|
|
||||||
| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
|
|
||||||
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
|
|
||||||
| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) |
|
|
||||||
| v1.4 | Reminder notifications — see below | complete (shipped 2026-06-11) |
|
|
||||||
| v2.0 | Conflict dialog, polish pass (store copy refresh, F-Droid screenshots), release | complete (shipped 2026-06-11) |
|
|
||||||
|
|
||||||
v2.0 scope was re-cut on 2026-06-11, after v1.4:
|
|
||||||
- **Occurrence edit** already shipped early, in v1.3.
|
|
||||||
- **Quick-add** is **cut from scope**: the full form already opens prefilled
|
|
||||||
(visible day, last-used calendar, optional fields hidden), so the sheet
|
|
||||||
would only save one screen transition while adding a second create-surface
|
|
||||||
to maintain. Revisit only if real-world feedback says creation feels heavy.
|
|
||||||
- **Calendar switching while editing** moves to the v3 backlog (sync-adapter
|
|
||||||
minefield: `CALENDAR_ID` is sync-adapter-owned, AOSP locks the field; an
|
|
||||||
honest implementation is copy+delete like Google Calendar, with sync-identity
|
|
||||||
and attendee side effects).
|
|
||||||
- **Conflict dialog** stays (plan 03, decision 5): on save, compare against
|
|
||||||
the row as it was when the form loaded; on external change, ask
|
|
||||||
overwrite / discard. Closes the silent-clobber gap on synced calendars.
|
|
||||||
|
|
||||||
## v1.4 — Reminder Notifications
|
|
||||||
|
|
||||||
**Essential**, not nice-to-have: Calendula targets users for whom it is their
|
|
||||||
*only* calendar app, so reminder delivery can't be delegated to Google/OEM
|
|
||||||
Calendar. The calendar provider schedules reminders and broadcasts
|
|
||||||
`android.intent.action.EVENT_REMINDER`, but it does **not** post the visible
|
|
||||||
notification — a calendar app must. We become that app (the Etar model).
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Manifest-registered `BroadcastReceiver` for `EVENT_REMINDER`
|
|
||||||
(data scheme `content://com.android.calendar`) — wakes us at reminder time,
|
|
||||||
no foreground service.
|
|
||||||
- Read `CalendarContract.CalendarAlerts` / `Reminders`, filter to
|
|
||||||
`METHOD_ALERT` / `METHOD_DEFAULT` (skip `METHOD_EMAIL`); post on a dedicated
|
|
||||||
notification channel; tap opens event detail.
|
|
||||||
- `POST_NOTIFICATIONS` runtime permission (API 33+) — requested in onboarding.
|
|
||||||
- Onboarding step: (a) request `POST_NOTIFICATIONS`, (b) in-app reminders
|
|
||||||
toggle, **default ON**, with copy warning that a second calendar app with
|
|
||||||
notifications on will cause duplicate reminders. Mirrored into Settings
|
|
||||||
(reversible).
|
|
||||||
|
|
||||||
Deliberately deferred (add only if needed):
|
|
||||||
- Snooze / dismiss notification actions (Etar has them)
|
|
||||||
- Battery-optimization exemption prompt for delivery reliability
|
|
||||||
|
|
||||||
## v2.1 — Month event grid + drawer view tabs (shipped 2026-06-15)
|
|
||||||
|
|
||||||
- Month grid shows real events as continuous multi-day bars (not just dots)
|
|
||||||
- View section in the navigation drawer to switch Month / Week / Day
|
|
||||||
- Fix: text cursor no longer jumps in event text fields
|
|
||||||
|
|
||||||
## v2.2 — Tap-to-create + local calendar management (shipped 2026-06-16)
|
|
||||||
|
|
||||||
- Tap an empty slot in day/week → create form prefilled with that day + the
|
|
||||||
tapped hour (snapped to the hour, 1 h long)
|
|
||||||
- Local (device-only) calendar management in a full-screen editor from
|
|
||||||
Settings → Calendars: create / rename / recolor / delete, with name,
|
|
||||||
pastel-previewed colour, and description (stored in `CAL_SYNC1`)
|
|
||||||
- Synced calendars listed read-only, grouped by account, each with a
|
|
||||||
per-account "manage in source app" deep-link (resolved from the account's
|
|
||||||
authenticator — DAVx5/ICSx5/…) + an add-account shortcut
|
|
||||||
- Shared `InlineTextField` extracted to `ui.common` (event form + calendar
|
|
||||||
editor share one input style)
|
|
||||||
|
|
||||||
## v2.3 — Material 3 grouped-list redesign (shipped 2026-06-16)
|
|
||||||
|
|
||||||
A structural + visual pass adopting one shared blueprint (modelled on the ReFra
|
|
||||||
gallery app) across Settings, the calendar manager and the navigation drawer.
|
|
||||||
|
|
||||||
- Shared `ui/common/GroupedList.kt`: `CollapsingScaffold` (a `LargeTopAppBar`
|
|
||||||
whose title collapses on scroll) + `GroupedRow` (Position-based corner
|
|
||||||
grouping, press-animated corners, `selected` + `minHeight` knobs).
|
|
||||||
- Settings: category hub with About card on top and sliding sub-pages
|
|
||||||
(Appearance / New event form / Notifications); theme/week-start/language
|
|
||||||
pickers moved from `DropdownMenu` to OptionCard dialogs; token-based icon
|
|
||||||
chips; `ic_gitea.xml` for the About "Source" button.
|
|
||||||
- Calendar manager + drawer restyled to match; shared `CalendarColorChip`;
|
|
||||||
drawer scrolls as one with the active view highlighted.
|
|
||||||
- Cards use `surfaceContainerHigh` for readable contrast.
|
|
||||||
- Donate button on the About card deferred (target TBD).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Backlog (theme-based, post-v2.1)
|
|
||||||
|
|
||||||
The old v3.0 / "daily-driver polish" / "Locations & People" lists are
|
|
||||||
consolidated here by theme. Within a group, **(in progress)** /
|
|
||||||
**(next)** mark what is being or about to be worked; everything else is an
|
|
||||||
approved-but-unscheduled idea unless tagged **(idea)** /
|
|
||||||
**(go/no-go)** / **(rejected)**. Order across groups is not a commitment.
|
|
||||||
|
|
||||||
## Near-term sequence (ranked, 2026-06-16)
|
|
||||||
|
|
||||||
The theme groups below are the full menu; this is the committed *order* for
|
|
||||||
the next stretch. Ranking favours finishing the current create/edit + calendar
|
|
||||||
arc before opening new fronts, then cheap-relative-to-value items and ones that
|
|
||||||
unblock a later item. Order is a plan, not a contract — revisit after each lands.
|
|
||||||
|
|
||||||
**Tier 1 — finish the current arc (create/edit + calendars)**
|
|
||||||
1. Tap-to-create in day/week *(shipped v2.2.0)* — prefilled create from an empty slot
|
|
||||||
2. Local calendar management + "manage in source app" deep-links *(shipped v2.2.0)*
|
|
||||||
3. ~~Settings redesign & restructure~~ *(shipped v2.3.0 — grew into the full
|
|
||||||
grouped-list blueprint across Settings + calendars + drawer; see "v2.3"
|
|
||||||
above)*
|
|
||||||
4. ~~Per-event color~~ *(shipped v2.4.0)* — palette calendars write
|
|
||||||
`EVENT_COLOR_KEY` (sync-safe); local/opted-in calendars write a raw
|
|
||||||
`EVENT_COLOR`; off-by-default setting for no-palette synced calendars
|
|
||||||
Tier 1's create/edit + calendars arc is effectively closed. **Duplicate event**
|
|
||||||
was deprioritised (2026-06-17) as low-importance and dropped to the bottom of
|
|
||||||
the sequence; the next item is now **Jump-to-date** (formerly Tier 2).
|
|
||||||
|
|
||||||
(Tier 2+ numbering below shifts accordingly; ranking unchanged.)
|
|
||||||
|
|
||||||
### Settings redesign & restructure *(shipped v2.3.0)*
|
|
||||||
|
|
||||||
The original scope below is kept as a record; the implementation expanded from a
|
|
||||||
sub-screen restructure into the shared grouped-list blueprint (see "v2.3" above).
|
|
||||||
|
|
||||||
The settings screen has grown into a flat vertical scroll of divider-separated
|
|
||||||
sections (Appearance, Event form, Notifications, Calendars, Language, About) and
|
|
||||||
will keep accreting rows (per-event-color defaults, default reminder, more
|
|
||||||
calendar entries are all queued). It needs structure before it gets unwieldy.
|
|
||||||
|
|
||||||
**Decided (2026-06-16): sub-screens**, not flat-but-carded. The top level
|
|
||||||
becomes a category list; each category opens its own destination. More
|
|
||||||
M3-idiomatic for a settings surface that will keep growing, and it mirrors the
|
|
||||||
existing Calendars row, which already navigates out to its own screen.
|
|
||||||
|
|
||||||
Structure — top-level settings list → category destinations:
|
|
||||||
- **Appearance** → theme, dynamic colour, week start
|
|
||||||
- **Event form** → the 6 default-field toggles + the hint text
|
|
||||||
- **Notifications** → reminders toggle (POST_NOTIFICATIONS flow stays)
|
|
||||||
- **Calendars** → already its own screen (`CalendarsScreen`); just becomes a
|
|
||||||
peer category row, no change to that screen
|
|
||||||
- **Language** → single control; keep as a top-level row that opens an
|
|
||||||
OptionCard directly (a whole sub-screen for one choice is overkill)
|
|
||||||
- **About** → kept inline on the top-level list as a card (read-only info,
|
|
||||||
not worth a navigation hop). Card layout, top → bottom:
|
|
||||||
- **Identity** — app logo + name "Calendula", with "by Jean-Luc Makiola"
|
|
||||||
as a subtitle beneath the name
|
|
||||||
- **Action buttons** (small, button-styled, sit in a row):
|
|
||||||
- **Source** — Gitea logo, opens the repo (`about_source_url`)
|
|
||||||
- **License** — opens the LICENSE file on Gitea
|
|
||||||
- **Donate** *(tentative)* — sits next to Source; target TBD (decide
|
|
||||||
before building: Liberapay / Ko-fi / Gitea sponsor / etc.)
|
|
||||||
- **Version** — small version number at the bottom of the card
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- **Navigation** — add the settings sub-screen destinations alongside the
|
|
||||||
existing settings/calendars routes in `CalendarHost`; back pops to the
|
|
||||||
settings list (mind the existing `BackHandler` that guards against falling
|
|
||||||
through to the activity).
|
|
||||||
- **Fix the dialog-pattern violation** — theme, week-start and language use
|
|
||||||
`DropdownMenu`; the project default is the full-width tonal OptionCard modal
|
|
||||||
(radio/dropdown/text-list dialogs are banned, see
|
|
||||||
`option-card-modal-style-default`). Migrate these selectors to OptionCard.
|
|
||||||
- **Visual pass** — top-level category rows with leading icons; consistent
|
|
||||||
spacing and row affordances aligned with the event-form card design system.
|
|
||||||
|
|
||||||
Out of scope (no new settings *features* here) — this is a structure + style
|
|
||||||
pass on the existing controls; new toggles ride in with their own features.
|
|
||||||
|
|
||||||
**Tier 2 — navigation & daily-driver completeness**
|
|
||||||
5. ~~Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap~~ *(done, v2.5.0)*
|
|
||||||
6. ~~Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget~~ *(done, v2.5.0)*
|
|
||||||
|
|
||||||
**Tier 3 — platform reach (depends on Tier 2)**
|
|
||||||
7. ~~Home-screen widget — built on the agenda data source from #6~~ *(done, v2.5.0 — agenda + month widgets)*
|
|
||||||
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; ~~quick-settings tile~~ *(done, v2.8.0 — "New event" QS tile)*
|
|
||||||
|
|
||||||
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
|
|
||||||
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
|
|
||||||
default reminder **+ per-calendar override**, bundled with battery-exemption
|
|
||||||
hardening. Full sketch in "Reminders — defaults & delivery reliability" below.
|
|
||||||
10. **The `.ics` engine — export + import** *(shipped v2.7.0, 2026-06-18)* — one
|
|
||||||
hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`),
|
|
||||||
four surfaces all shipped: single-event share + whole-calendar backup
|
|
||||||
(export), open-`.ics`→form + whole-calendar bulk import (import). Closed the
|
|
||||||
device-local-calendar data-loss gap (#10/#11 merged here). Built as two
|
|
||||||
sequential branches: `feat/ics-export` (write side + UID-on-create precursor)
|
|
||||||
then `feat/ics-import` (parser, restore, dedup by UID). Import is
|
|
||||||
liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` / `RECURRENCE-ID`
|
|
||||||
/ guest lists it can't model. Plans:
|
|
||||||
`docs/superpowers/plans/2026-06-18-05-ics-export.md` + `…-06-ics-import.md`.
|
|
||||||
11. **Snooze / dismiss notification actions** *(merged into release/v2.8.0)* —
|
|
||||||
followed the `.ics` work; inherits v2.6's deferred exact-alarm/WorkManager
|
|
||||||
decision (snooze must re-fire an alarm).
|
|
||||||
|
|
||||||
**Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)*
|
|
||||||
12. **Attendee editing** *(shipped — merged into release/v2.8.0, `feat/attendee-editing`
|
|
||||||
MR !29 / commit `b0f34ff`, 2026-06-22)* — closed the last big read-only gap in
|
|
||||||
the event model: attendees were already *read* (queried, mapped, shown on the
|
|
||||||
detail screen since v0.6) and are now *writable* from the form. An attendees
|
|
||||||
section on `EventEditScreen` / `EventForm` adds by typed email **or** the
|
|
||||||
no-permission contact picker, edits/removes rows, and sets role (required /
|
|
||||||
optional) — writing `CalendarContract.Attendees` rows on insert + dirty-checked
|
|
||||||
update, mirroring the reminders-diff pattern. The sync-adapter *invitation*
|
|
||||||
caveat was **settled record-only** (Calendula never sends invites; the backend
|
|
||||||
decides delivery) — full sketch + decision in "Attendee editing" under Locations
|
|
||||||
& People below.
|
|
||||||
|
|
||||||
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
|
|
||||||
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
|
|
||||||
- Locations & People — the no-permission contact pickers already shipped (location v2.8.0, attendee email v2.8.0); what remains gated is OSM autocomplete (needs INTERNET)
|
|
||||||
- Move event to another calendar — sync-adapter minefield (copy+delete model)
|
|
||||||
|
|
||||||
**Bottom — deprioritised, not important**
|
|
||||||
- Duplicate event (detail action → prefilled create form) — moved here
|
|
||||||
2026-06-17; cheap but low value, pick up only if asked
|
|
||||||
|
|
||||||
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts.
|
|
||||||
Pulled in opportunistically, not sequenced.
|
|
||||||
|
|
||||||
Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics`
|
|
||||||
export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected),
|
|
||||||
and Tier 5 #12 — attendee editing — shipped in release/v2.8.0, closing the last
|
|
||||||
read-only gap in the event model. v2.8.0 also cleared most Tier 2/3 leftovers —
|
|
||||||
full-text search, the "New event" Quick Settings tile, and the now-line all
|
|
||||||
shipped there. **No tier work is currently committed as next;** the remaining
|
|
||||||
candidates are unscheduled theme-group ideas (pinch-to-zoom, tablet/foldable,
|
|
||||||
accessibility pass) plus the gated go/no-go items.
|
|
||||||
|
|
||||||
## Navigation & views
|
|
||||||
|
|
||||||
- ~~Tap an empty slot in day/week → create form prefilled with that
|
|
||||||
date+time, snapped to the hour~~ **shipped v2.2.0** (long-press variant
|
|
||||||
not added — single tap covers it)
|
|
||||||
- Agenda view (fourth view: upcoming events grouped by day; also the
|
|
||||||
natural data source for a future widget)
|
|
||||||
- Jump to date — drawer date picker (un-cut from V1)
|
|
||||||
- ~~Current-time "now" line in day/week~~ **shipped v2.8.0**
|
|
||||||
- Week numbers in the **month** grid — **rejected** (owner decision): clutters
|
|
||||||
the view and shrinks the day cells; the badge stays week-view-only.
|
|
||||||
- Pinch-to-zoom time scale in day/week
|
|
||||||
- Tablet / foldable layouts *(was v3.0)*
|
|
||||||
- ~~Full-text search~~ **shipped v2.8.0** — for a daily driver with real event
|
|
||||||
history, finding an event is core completeness, not optional.
|
|
||||||
|
|
||||||
## Event editing & creation
|
|
||||||
|
|
||||||
- Duplicate event (detail action → prefilled create form)
|
|
||||||
- ~~**Per-event color**~~ *(shipped v2.4.0)* — `EVENT_COLOR` / `EVENT_COLOR_KEY`
|
|
||||||
from the calendar's color list (`Colors` table, `TYPE_EVENT`), OptionCard
|
|
||||||
picker in the form, falling back to the calendar color when unset. Reused the
|
|
||||||
color-picker component and palette plumbing from local calendar management and
|
|
||||||
finished the create/edit theme.
|
|
||||||
|
|
||||||
## Calendars & accounts
|
|
||||||
|
|
||||||
- ~~Create / manage local (device-only) calendars~~ **shipped v2.2.0** —
|
|
||||||
name + color + description; rename / recolor / delete the calendars the app
|
|
||||||
owns. Inserted under `ACCOUNT_TYPE_LOCAL` as a sync adapter; description in
|
|
||||||
`CAL_SYNC1`. Full-screen "Calendars" editor reached from Settings.
|
|
||||||
- ~~Per-calendar "manage in source app" deep-link~~ **shipped v2.2.0** — for
|
|
||||||
synced calendars, open the app the calendar actually came from based on
|
|
||||||
its `ACCOUNT_TYPE` (DAVx5 `bitfire.at.davdroid`, Google `com.google`,
|
|
||||||
…); fall back to system account/sync settings. Plus an "add account"
|
|
||||||
entry into system Accounts. Honest boundary for remote calendars.
|
|
||||||
- **Remote calendar create/edit** *(go/no-go)* — creating a CalDAV
|
|
||||||
collection (`MKCALENDAR`) or a Google calendar means an in-app sync
|
|
||||||
client: **INTERNET permission, credential storage, the full server
|
|
||||||
round-trip** — i.e. re-implementing DAVx5. DAVx5 exposes no public
|
|
||||||
intent to delegate the create to it. Cosmetic local edits (color/name)
|
|
||||||
to an existing synced row are possible but don't propagate to the server
|
|
||||||
and may be overwritten on next sync — not promised. Same explicit
|
|
||||||
go/no-go gate as the OSM/INTERNET item below.
|
|
||||||
- Move event to another calendar (copy+delete model with a consequences
|
|
||||||
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
|
|
||||||
- ~~**Local-calendar backup / export** *(Tier 4 #10)*~~ **shipped v2.7.0** —
|
|
||||||
device-only (`ACCOUNT_TYPE_LOCAL`) calendars had no sync and therefore no
|
|
||||||
backup. Settings → Calendars → Export writes every event to a user-chosen
|
|
||||||
`.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by
|
|
||||||
UID). Closed the silent data-loss gap.
|
|
||||||
|
|
||||||
### Disable a calendar in-app *(captured 2026-06-25)*
|
|
||||||
|
|
||||||
A second, heavier visibility level **above** the existing per-view filter. Today
|
|
||||||
the drawer's calendar filter (`hiddenCalendarIds` in `CalendarPrefs`) only hides
|
|
||||||
a calendar's *events* from the month/week/day/agenda views — the calendar itself
|
|
||||||
still clutters the drawer filter list, the event-form calendar picker, and the
|
|
||||||
import target picker. "Disable" removes a calendar from the app's surfaces
|
|
||||||
entirely; "hide" stays the lightweight, frequently-toggled control.
|
|
||||||
|
|
||||||
**Two-level model (both kept):**
|
|
||||||
- **Hidden** (existing) — `hiddenCalendarIds`; a quick per-view checkbox in the
|
|
||||||
drawer. Toggles events on/off in the views; the calendar stays listed
|
|
||||||
everywhere. Operates only over the *enabled* calendars.
|
|
||||||
- **Disabled** (new) — the calendar is gone from the app: not in the drawer
|
|
||||||
filter list, not in the event-form picker, not in the import picker, and its
|
|
||||||
events never appear (it's gated out of `instances()` like a hidden one). It
|
|
||||||
remains visible **only** in Settings → Calendars, where the enable/disable
|
|
||||||
toggle lives, so it can be brought back.
|
|
||||||
|
|
||||||
**Storage — app-side (DataStore), mirrors the hidden set.** Add
|
|
||||||
`disabledCalendarIds: Set<Long>` + `setDisabledCalendarIds(...)` to
|
|
||||||
`CalendarPrefs` (comma-separated string key, same shape as `hiddenCalendarIds`).
|
|
||||||
**Does not touch** `CalendarContract.Calendars.VISIBLE` / `SYNC_EVENTS` — purely
|
|
||||||
a Calendula-local preference, so other calendar apps are unaffected and the sync
|
|
||||||
adapters stay out of it (privacy-clean, reversible).
|
|
||||||
|
|
||||||
**Where the disabled set is applied:**
|
|
||||||
- `CalendarRepositoryImpl.instances()` + `searchEvents()` — exclude
|
|
||||||
`calendarId ∈ (hidden ∪ disabled)` so a disabled calendar's events never show
|
|
||||||
and aren't searchable. (`repository.calendars()` itself stays unfiltered/raw —
|
|
||||||
the screens that need everything still get everything.)
|
|
||||||
- `FilterViewModel.state` — drop disabled calendars from the drawer filter list
|
|
||||||
(you can't hide/show what's disabled).
|
|
||||||
- `EventEditViewModel.writableCalendars` — exclude disabled, so you can't create
|
|
||||||
into a calendar you've removed from the app. Handle the last-used-calendar
|
|
||||||
preselect falling on a now-disabled calendar (fall back to first enabled
|
|
||||||
writable).
|
|
||||||
- `ImportViewModel` — exclude disabled from the import target list.
|
|
||||||
- `CalendarsScreen` / `CalendarsViewModel` — the **only** surface that lists
|
|
||||||
disabled calendars; add `setDisabled(id, Boolean)` and a per-calendar toggle.
|
|
||||||
|
|
||||||
**UI — Settings → Calendars (no new tab).** Add an enable/disable control to each
|
|
||||||
row on the existing `CalendarsScreen` (both the local and the synced/read-only
|
|
||||||
groups — disabling is an app-side view choice, independent of write access).
|
|
||||||
Disabled rows render visibly de-emphasised (dimmed) but keep the toggle so
|
|
||||||
they're re-enableable. Follow the project dialog/list conventions (M3 grouped
|
|
||||||
list, `option-card-modal-style-default` if a confirm/selection surface is needed).
|
|
||||||
|
|
||||||
**Decided behaviour:**
|
|
||||||
- All calendars disabled → views show the existing empty state.
|
|
||||||
- A disabled calendar that still holds events: events simply vanish from
|
|
||||||
views/search until re-enabled (no data touched — it's a filter, not a delete).
|
|
||||||
- Deep links / notifications pointing at an event in a disabled calendar still
|
|
||||||
open its detail screen — detail is a direct id lookup, not an `instances()`
|
|
||||||
query, so disabling never strands an existing link.
|
|
||||||
|
|
||||||
## Reminders — defaults & delivery reliability *(shipped v2.6.0; built on `feat/default-reminders`)*
|
|
||||||
|
|
||||||
Two themes bundled because both are "make reminders trustworthy" — the core of
|
|
||||||
the "Calendula is your only calendar app" promise.
|
|
||||||
|
|
||||||
**Built in this slice (A + the safe half of B):** global timed default reminder
|
|
||||||
+ a **separate all-day default** (day-scale lead times) + per-calendar override
|
|
||||||
(timed events), applied on create with manual-edit / calendar-switch / all-day-
|
|
||||||
toggle handling; three pickers + per-calendar override list in Settings →
|
|
||||||
Notifications; battery-optimisation exemption row (status + system deep-link, no
|
|
||||||
extra permission). `resolveDefaultReminder` + prefs round-trips unit-tested.
|
|
||||||
Resolution model: all-day events use the all-day global default outright;
|
|
||||||
per-calendar overrides govern timed events only. Reviewed (8-angle), fixes
|
|
||||||
applied: form-reset state race, label-fn consolidation with the detail screen,
|
|
||||||
inline wrapper + single combined flow read.
|
|
||||||
|
|
||||||
**Deliberately deferred (documented decisions, not oversights):**
|
|
||||||
- *Absolute time-of-day for all-day reminders* — the all-day default is still
|
|
||||||
minutes-before-midnight (day-scale presets), not "9am the day before" (open
|
|
||||||
decision #2's richer half). Per-calendar all-day overrides also deferred.
|
|
||||||
- *Self-scheduled alarms* — kept the existing provider-broadcast architecture
|
|
||||||
(open decision #1). The battery exemption is the reliability lever; no
|
|
||||||
`AlarmManager`/`USE_EXACT_ALARM` subsystem was added.
|
|
||||||
- *Test-reminder diagnostic* and *battery prompt inside onboarding* — the
|
|
||||||
exemption lives only in Settings for now (onboarding flow untouched to keep
|
|
||||||
the change reviewable).
|
|
||||||
|
|
||||||
### A. Default reminders (global + per-calendar override)
|
|
||||||
|
|
||||||
**No provider backing.** `CalendarContract` has no column that auto-applies a
|
|
||||||
default reminder per calendar — Google's per-calendar defaults live server-side.
|
|
||||||
So both the global default *and* the per-calendar override are **app-side
|
|
||||||
preferences**, applied by us at event-insert time. We inherit nothing from the
|
|
||||||
synced calendar.
|
|
||||||
|
|
||||||
- **Storage (DataStore):**
|
|
||||||
- `defaultReminderMinutes: Int?` — global default; `null` = "no reminder".
|
|
||||||
- `defaultAllDayReminderMinutes: Int?` — separate all-day default (all-day
|
|
||||||
reminders are expressed as minutes before midnight / day-before-at-time, not
|
|
||||||
minutes before a start instant — they need their own value).
|
|
||||||
- `perCalendarReminderOverride: Map<Long, Int?>` — keyed by calendar id;
|
|
||||||
**absent key = inherit global**, explicit `null` = "no reminder for this
|
|
||||||
calendar". (Same for an all-day override map if we want per-calendar all-day.)
|
|
||||||
- **Apply on create:** a fresh event prefills its reminders list from
|
|
||||||
override-or-global for the preselected calendar. Changing the calendar in the
|
|
||||||
form re-applies the *new* calendar's default **only if the user hasn't manually
|
|
||||||
edited the reminders** — track a dirty flag, mirroring the per-event-color
|
|
||||||
reset pattern (v2.4).
|
|
||||||
- **Edit semantics:** defaults apply to **new events only**; never rewrite
|
|
||||||
reminders on existing events on open or on calendar-switch-during-edit.
|
|
||||||
- **Settings UI (Notifications sub-page):**
|
|
||||||
- Global default via OptionCard (None / at time of event / 5 / 10 / 15 / 30 min
|
|
||||||
/ 1 h / 1 day / custom), plus the separate all-day default.
|
|
||||||
- Per-calendar overrides: a row per writable calendar (in the Calendars screen
|
|
||||||
or a Notifications subsection), each opening the same OptionCard with a
|
|
||||||
leading **"Use global default"** option.
|
|
||||||
|
|
||||||
### B. Delivery reliability (exact alarms + battery)
|
|
||||||
|
|
||||||
The provider broadcasts `EVENT_REMINDER`, but on modern Android (Doze / OEM
|
|
||||||
battery managers) delivery can be silently delayed or dropped. v1.4 deferred this;
|
|
||||||
it directly undermines the feature's premise, so it rides in here.
|
|
||||||
|
|
||||||
- **Exact alarm — decision first:** trust the provider broadcast, or
|
|
||||||
self-schedule via `AlarmManager.setExactAndAllowWhileIdle` for reliability?
|
|
||||||
If we self-schedule, declare `USE_EXACT_ALARM` (API 33+, auto-granted for
|
|
||||||
calendar/alarm-category apps, F-Droid-clean) with a `SCHEDULE_EXACT_ALARM`
|
|
||||||
fallback for API 31–32 (user-revocable → settings deep-link prompt).
|
|
||||||
- **Battery-optimization exemption:** a *soft, optional* prompt via
|
|
||||||
`ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (settings deep-link — never the
|
|
||||||
auto-grant intent), honest copy: "Android may delay reminders to save battery;
|
|
||||||
exempt Calendula for on-time delivery." Shown once after the existing
|
|
||||||
`POST_NOTIFICATIONS` onboarding step, reversible in Settings → Notifications.
|
|
||||||
- **Diagnostics:** a "send a test reminder in 1 minute" button in Notifications
|
|
||||||
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
|
|
||||||
are notorious for suppressing it).
|
|
||||||
|
|
||||||
### Decisions made (as shipped in v2.6.0)
|
|
||||||
|
|
||||||
1. **Provider broadcast kept** — did not self-schedule via `AlarmManager`; the
|
|
||||||
battery-optimisation exemption is the reliability lever (simplicity + battery
|
|
||||||
cost won over the exact-alarm subsystem).
|
|
||||||
2. **All-day reminders = minutes-before-midnight** (day-scale presets); absolute
|
|
||||||
time-of-day ("9am the day before") deferred — see the deferred list above.
|
|
||||||
3. **Per-calendar overrides live in the Notifications sub-page** (override list),
|
|
||||||
governing timed events only.
|
|
||||||
|
|
||||||
### Round two
|
|
||||||
|
|
||||||
- ~~Snooze + dismiss actions on the notification~~ *(shipped in release/v2.8.0,
|
|
||||||
Tier 4 #11)* — snooze re-fires a snooze-only exact alarm.
|
|
||||||
|
|
||||||
## Sharing & interop
|
|
||||||
|
|
||||||
- ~~Share event as .ics + open/receive .ics into a prefilled create form~~
|
|
||||||
**shipped v2.7.0** — single-event share from detail; opening an `.ics` with one
|
|
||||||
event prefills the create form, many events opens a bulk import (dedup by UID)
|
|
||||||
- ~~ICS file import~~ **shipped v2.7.0** — covered by the open/receive `.ics`
|
|
||||||
flow above (single → form, many → bulk import)
|
|
||||||
|
|
||||||
## Platform & launchers
|
|
||||||
|
|
||||||
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
|
|
||||||
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0**
|
|
||||||
- ~~"New event" Quick Settings tile~~ **shipped v2.8.0**
|
|
||||||
|
|
||||||
## Quality & reliability
|
|
||||||
|
|
||||||
- **Accessibility pass** — TalkBack content descriptions across all screens,
|
|
||||||
dynamic-type / large-font reflow, touch-target audit. Quality bar for an
|
|
||||||
F-Droid app; nothing tracks it yet.
|
|
||||||
- **Reminder delivery reliability** — exact alarms + battery-optimization
|
|
||||||
exemption; specced in the "Reminders — defaults & delivery reliability" slice
|
|
||||||
above (Tier 4 #9).
|
|
||||||
|
|
||||||
## Locations & People *(go/no-go, captured 2026-06-11)*
|
|
||||||
|
|
||||||
Beyond classic calendar-client scope; discussed, deliberately not planned
|
|
||||||
in detail yet:
|
|
||||||
|
|
||||||
- ~~**Contact address picker** for the location field via the system picker
|
|
||||||
(`ACTION_PICK`)~~ **shipped v2.8.0** — one-shot, needs no READ_CONTACTS, fits
|
|
||||||
the privacy story. The same no-permission mechanism was then reused for the
|
|
||||||
attendee email picker (v2.8.0).
|
|
||||||
- **OSM address autocomplete** in the location field (type "Brandenburger
|
|
||||||
Tor" → tap suggestion → resolved address inserted). Backend would be
|
|
||||||
Photon (Nominatim's public policy forbids autocomplete). **Requires the
|
|
||||||
INTERNET permission** — first dent in the "no network access" promise;
|
|
||||||
if built: opt-in (off by default), honest copy, configurable endpoint
|
|
||||||
for self-hosters, onboarding footnote + F-Droid copy reworded. This
|
|
||||||
trade-off is an explicit go/no-go decision before any work starts.
|
|
||||||
- **Inline contact suggestions** while typing (needs READ_CONTACTS) — only
|
|
||||||
if the picker proves clunky.
|
|
||||||
- **Attendee editing** *(promoted out of this gated bucket 2026-06-22 — now
|
|
||||||
Tier 5 #12, high-importance; the no-permission typed-email path is not an
|
|
||||||
INTERNET/contacts call)*. See the "Attendee editing" sketch below.
|
|
||||||
|
|
||||||
### Attendee editing *(Tier 5 #12, opened 2026-06-22)*
|
|
||||||
|
|
||||||
The last read-only gap in the event model: attendees are read & shown on the
|
|
||||||
detail screen (since v0.6) but the form can't write them. Make guests editable.
|
|
||||||
|
|
||||||
- **Read side already done:** `Attendee` domain model + status/relationship/type
|
|
||||||
enums, `queryAttendees` + `EventDetailMapper.toAttendee` (with tests), and the
|
|
||||||
attendees `DetailCard` + `AttendeeRow` in `EventDetailScreen`. Nothing to add
|
|
||||||
there.
|
|
||||||
- **Write side — SHIPPED (`feat/attendee-editing`, commit b0f34ff, 2026-06-22):**
|
|
||||||
`attendees` on `EventForm`; a Guests section in `EventEditScreen` rendered as an
|
|
||||||
inline grouped list — each guest a tonal card (avatar, name/email, tappable
|
|
||||||
Required/Optional role chip, remove), the trailing card an inline email field
|
|
||||||
(type → Done commits). Persisted by `reconcileAttendees` diffing the provider's
|
|
||||||
`CalendarContract.Attendees` rows on insert + dirty-checked update (new guests =
|
|
||||||
`RELATIONSHIP_ATTENDEE` / `STATUS_INVITED`; kept rows keep their status, only the
|
|
||||||
required/optional type updates; organizer/resource/no-email rows untouched).
|
|
||||||
Reminders restyled to the same grouped-list pattern in passing. Needs only the
|
|
||||||
existing `WRITE_CALENDAR` — no new permission.
|
|
||||||
- **Name on manual add — SHIPPED via a contact picker** (2026-06-22). The inline
|
|
||||||
field is email-only (the quick path); the add row also has a **"from contacts"
|
|
||||||
button**: `ACTION_PICK` on `ContactsContract.CommonDataKinds.Email.CONTENT_URI` →
|
|
||||||
the system Contacts app returns a URI to the picked email row, queried for
|
|
||||||
`Email.ADDRESS` **and** the contact `DISPLAY_NAME`, so a picked guest gets both
|
|
||||||
email and name in one tap. The result Intent grants temporary read access, so
|
|
||||||
**no `READ_CONTACTS` permission** — one-shot and user-driven, same no-permission
|
|
||||||
mechanism as the location address picker above. Self/organizer rows stay
|
|
||||||
non-editable.
|
|
||||||
- **Invitation behavior — DECIDED 2026-06-22: record-only, all writable
|
|
||||||
calendars.** Calendula has no INTERNET and never sends an invitation itself; it
|
|
||||||
only writes `Attendees` rows. Whether a guest is notified is decided downstream:
|
|
||||||
local calendars notify no one (no sync); CalDAV/DAVx5 PUTs the `ATTENDEE` lines
|
|
||||||
and the *server* decides iMIP delivery; Google's sync adapter pushes the change
|
|
||||||
and Google decides (third-party attendee writes are historically unreliable
|
|
||||||
there). Editing is allowed on **any writable calendar** — not gated to local.
|
|
||||||
- **Honest, backend-aware copy is mandatory** (this is the whole point of the
|
|
||||||
decision): on a synced calendar show "Calendula doesn't send invitations —
|
|
||||||
your calendar account may email guests when it syncs"; on a local calendar
|
|
||||||
show "Stored on this device. No one is notified."
|
|
||||||
- Calendula must **not fabricate an ORGANIZER** or otherwise fake scheduling
|
|
||||||
state to coax a send — it writes the guest list faithfully and leaves
|
|
||||||
scheduling entirely to the backend.
|
|
||||||
- The optional "send an .ics invite via your email app" delegate (`ACTION_SEND`,
|
|
||||||
still no INTERNET) was considered and **deferred** — revisit only if users ask
|
|
||||||
to notify guests explicitly.
|
|
||||||
- **Out of scope (for now):** RSVP/your-own-response editing, free/busy lookups,
|
|
||||||
resource booking — all carry server round-trips or richer sync semantics.
|
|
||||||
|
|
||||||
## Consciously rejected
|
|
||||||
|
|
||||||
- Travel time / weather / smart suggestions (network, core-promise conflict)
|
|
||||||
- Natural-language quick entry (high effort, locale-fragile; the prefilled
|
|
||||||
form already covers fast entry)
|
|
||||||
- Quick-add sheet (the prefilled full form already covers it — cut in v2.0)
|
|
||||||
- Drag & drop rescheduling in day/week — **rejected** (owner decision,
|
|
||||||
reaffirmed 2026-06-22): not wanted. Rescheduling stays via the edit form.
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
# Calendula — Current State
|
|
||||||
|
|
||||||
*Last updated: 2026-06-22*
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11.
|
|
||||||
**Phase:** post-2.x theme-based backlog work (organised in `ROADMAP.md`).
|
|
||||||
**Latest released tag: v2.7.5.** The whole Tier 4 (reliability/data-safety/
|
|
||||||
interop) arc is now done or in flight:
|
|
||||||
- v2.4.0 per-event colors (2026-06-17)
|
|
||||||
- v2.5.0 jump-to-date, Agenda view, agenda + month home-screen widgets, "New
|
|
||||||
event" launcher shortcut (2026-06-17)
|
|
||||||
- v2.6.0 default reminders (global + per-calendar override, all-day default,
|
|
||||||
battery-exemption row) + system per-app language (2026-06-18)
|
|
||||||
- v2.7.0 **`.ics` engine** — single-event share, local-calendar backup export,
|
|
||||||
open/receive `.ics` (single → form, many → bulk import, dedup by UID)
|
|
||||||
(2026-06-18)
|
|
||||||
- v2.7.1–v2.7.5 — crash-reporting + F-Droid reproducible-build hardening + fixes
|
|
||||||
|
|
||||||
**Next release `release/v2.8.0` (integration branch, not yet cut to main):**
|
|
||||||
holds crash reports via the public Codeberg tracker (MR !27) + reminder
|
|
||||||
snooze/dismiss notification actions (MR !28). Version bump to 2.8.0 happens at
|
|
||||||
release-cut.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- [x] Design spec written and committed (`docs/superpowers/specs/2026-06-08-calendar-app-design.md`)
|
|
||||||
- [x] V1 design decisions resolved (App name "Calendula", icon, seed color)
|
|
||||||
- [x] Plan 01 written and executed — foundation lands (theme, icon, i18n, Hilt, DataStore, CI green)
|
|
||||||
- [x] Plan 02 written and executed — data layer + permission flow + debug screen
|
|
||||||
- [x] Month view (S1) — 6-week grid, event dots, today marker, swipe nav, three states (replaces debug screen)
|
|
||||||
- [x] Week view (S2) — time schedule with overlap-resolved lanes, all-day strip, swipe nav, three states
|
|
||||||
- [x] Day view (S3) — single-column slice reusing the week layout
|
|
||||||
- [x] View-switcher (M1) wired — cycles Month ↔ Week ↔ Day
|
|
||||||
- [x] Event-detail screen (S4) — full-screen, humanized recurrence
|
|
||||||
- [x] Filter sheet (M3) — per-calendar visibility, grouped by account, persisted, applied centrally in the repository
|
|
||||||
- [x] Settings (M4) — appearance (theme, dynamic colour, week start), language (per-app locales), about
|
|
||||||
- [~] Jump-to-date (M2) — **cut from scope**; "Today" half shipped in v0.5, date-picker dropped
|
|
||||||
- [x] Full event read (v0.6) — reminders, status, availability, access level,
|
|
||||||
attendee role + self-response, foreign timezone, and linkified description
|
|
||||||
URLs in the detail view; new domain enums + mapper unit tests. (A dedicated
|
|
||||||
URL field was cut — no `CalendarContract` column backs it.)
|
|
||||||
|
|
||||||
- [x] v1.1 write foundation — `WRITE_CALENDAR` (onboarding asks READ+WRITE,
|
|
||||||
only READ gates; contextual upgrade for v1.0 installs), read-only-calendar
|
|
||||||
detection (`CALENDAR_ACCESS_LEVEL` → `canModifyContents`, actions hidden for
|
|
||||||
WebCal/birthday calendars), delete from the detail screen (recurring:
|
|
||||||
"only this event" via cancelled exception / "all events in the series"),
|
|
||||||
repository + mapper tests
|
|
||||||
|
|
||||||
- [x] v1.2 create event — full-screen `EventEditScreen` (title, all-day,
|
|
||||||
M3 date/time pickers with duration-preserving start moves, writable-only
|
|
||||||
calendar picker preselecting the last-used calendar, location, description),
|
|
||||||
"+" FAB on all three views prefilled with the visible day, `insertEvent`
|
|
||||||
with provider-correct all-day normalisation (UTC midnights, exclusive end),
|
|
||||||
domain/mapper/repository tests
|
|
||||||
|
|
||||||
- [x] v1.3 edit event (shipped 2026-06-11) — `EventEditScreen` reused for
|
|
||||||
edit (detail-screen Edit action, `canModify`-gated, contextual WRITE
|
|
||||||
upgrade), dirty-checked partial `update` on the Events row (recurring:
|
|
||||||
series DTSTART moves by the user's delta, DURATION instead of DTEND),
|
|
||||||
reminder diff by minutes (kept rows keep their method), simple recurrence
|
|
||||||
picker (FREQ/INTERVAL/UNTIL/COUNT; complex RRULEs preserved verbatim and
|
|
||||||
shown humanized), `EventFormField.Recurrence` incl. settings default,
|
|
||||||
recurrence also available on create; domain/mapper/repository tests.
|
|
||||||
Review round 1: weekly BYDAY day-toggles in the custom picker ("every week
|
|
||||||
on Mon+Fri"). Review rounds 2–4: occurrence edit pulled forward from v2.0
|
|
||||||
and made three-way like delete ("this" = exception row via
|
|
||||||
`CONTENT_EXCEPTION_URI`, "this and following" = series split, "all" =
|
|
||||||
series update); delete equally three-way (truncation via RRULE UNTIL);
|
|
||||||
the edit-scope question moved to save time (Google model) — dirty
|
|
||||||
recurring saves park in `SaveUiState.AwaitingScope`, a changed rule drops
|
|
||||||
the "only this event" option
|
|
||||||
|
|
||||||
- [x] v1.4 reminder notifications (shipped 2026-06-11) — exported
|
|
||||||
`EVENT_REMINDER` receiver → `CalendarAlerts` (SCHEDULED & due) →
|
|
||||||
dedicated channel, tap opens detail (singleTop deep link); best-effort
|
|
||||||
FIRED marking; one-time onboarding step requesting `POST_NOTIFICATIONS`
|
|
||||||
with duplicate-reminders warning; Settings mirror. Provider only fires
|
|
||||||
`METHOD_ALERT` rows (AOSP-verified), so email reminders never reach us
|
|
||||||
|
|
||||||
- [x] v2.0 conflict dialog + store polish (shipped 2026-06-11 as v2.0.0) —
|
|
||||||
`EditSnapshot` compare on save (overwrite/discard; deleted → close),
|
|
||||||
quick-add cut, calendar-switch → v3 backlog; F-Droid/README copy
|
|
||||||
refreshed, fastlane screenshots DE+EN captured on-device
|
|
||||||
|
|
||||||
- [x] v2.1 (shipped 2026-06-15) — month grid shows real events as
|
|
||||||
continuous multi-day bars; navigation-drawer View section
|
|
||||||
(Month/Week/Day); cursor-jump fix in event text fields
|
|
||||||
|
|
||||||
- [x] v2.2 (shipped 2026-06-16) — tap an empty slot in day/week to create
|
|
||||||
(prefilled with that day + tapped hour, snapped to the hour); local
|
|
||||||
calendar management in a full-screen editor from Settings →
|
|
||||||
Calendars: create/rename/recolor/delete device-only calendars
|
|
||||||
(`ACCOUNT_TYPE_LOCAL`, sync-adapter insert) with name, pastel-previewed
|
|
||||||
colour, and description (stored in `CAL_SYNC1`); synced calendars listed
|
|
||||||
read-only grouped by account with a per-account "manage in source app"
|
|
||||||
deep-link (resolved from the account's authenticator: DAVx5/ICSx5/…) and
|
|
||||||
an add-account shortcut. Shared `InlineTextField` extracted to `ui.common`
|
|
||||||
|
|
||||||
- [x] v2.3 settings/calendars/drawer redesign (shipped 2026-06-16) — adopted a
|
|
||||||
shared Material 3 grouped-list blueprint, modelled on the ReFra gallery app
|
|
||||||
and extracted to `ui/common/GroupedList.kt` (`CollapsingScaffold` with a
|
|
||||||
`LargeTopAppBar` exit-until-collapsed title; `GroupedRow` with Position-based
|
|
||||||
corner grouping, press-animated corners, `selected` + `minHeight` knobs).
|
|
||||||
- Settings: category hub (About card on top → version mark at the foot) with
|
|
||||||
sliding sub-pages (Appearance / New event form / Notifications); token-
|
|
||||||
based icon chips; theme/week-start/language pickers migrated from
|
|
||||||
`DropdownMenu` to OptionCard dialogs. New `ic_gitea.xml` (Simple Icons,
|
|
||||||
verbatim path) for the About "Source" button; en+de strings.
|
|
||||||
- Calendar manager: same collapsing scaffold + grouped rows; shared
|
|
||||||
`CalendarColorChip` (neutral chip, pastelised calendar glyph).
|
|
||||||
- Navigation drawer: branded header, grouped View switcher (active view
|
|
||||||
highlighted via `secondaryContainer`), the filter list restyled to
|
|
||||||
grouped rows with a trailing checkbox; the whole drawer scrolls as one.
|
|
||||||
- Cards use `surfaceContainerHigh` for readable contrast against `surface`.
|
|
||||||
- Donate button on the About card deferred (target still TBD).
|
|
||||||
|
|
||||||
- [x] v2.4 per-event color (shipped 2026-06-17) — an optional "Color" field in
|
|
||||||
the event form. Read/render already resolved `EVENT_COLOR` with a calendar
|
|
||||||
fallback; this adds the write side and the picker. Palette-backed calendars
|
|
||||||
(Google, some CalDAV) pick from the account's `Colors` (`TYPE_EVENT`) and
|
|
||||||
write `EVENT_COLOR_KEY` so the color round-trips through sync; local
|
|
||||||
calendars write a raw `EVENT_COLOR` from the shared `CALENDAR_COLOR_PALETTE`
|
|
||||||
(extracted with the swatch row to `ui/common/ColorSwatchRow.kt`). Switching
|
|
||||||
calendars resets the choice (a key is account-scoped). A settings toggle
|
|
||||||
("Allow colors on unsupported calendars", off by default) extends the raw
|
|
||||||
path to synced calendars with no palette, with an honest "may not survive
|
|
||||||
sync" warning on the picker and in Settings. Color writes flow through
|
|
||||||
insert / dirty-checked update / occurrence-exception; mapper + form tests.
|
|
||||||
|
|
||||||
- [x] v2.5 (shipped 2026-06-17) — Agenda view (4th top-level view),
|
|
||||||
jump-to-date drawer date picker, two home-screen widgets (scrolling
|
|
||||||
"Upcoming" agenda + month grid), and a "New event" launcher long-press
|
|
||||||
shortcut
|
|
||||||
|
|
||||||
- [x] v2.6 (shipped 2026-06-18) — default reminders: global timed default +
|
|
||||||
separate all-day default + per-calendar override (timed), applied on create
|
|
||||||
with dirty-flag handling; three pickers + override list in Settings →
|
|
||||||
Notifications; battery-optimisation exemption row (status + system deep-link,
|
|
||||||
no new permission). Plus system per-app language (Android 13+) and an
|
|
||||||
immediate-effect fix for the in-app language picker
|
|
||||||
|
|
||||||
- [x] v2.7 (shipped 2026-06-18) — the `.ics` engine: share a single event as
|
|
||||||
`.ics` from the detail screen; back up local calendars (Settings → Calendars
|
|
||||||
→ Export) to a SAF file; open/receive an `.ics` — one event prefills the
|
|
||||||
create form, many events open a bulk import into a chosen calendar (dedup by
|
|
||||||
UID, skip-and-report unrepresentable VTIMEZONE / RECURRENCE-ID / guests).
|
|
||||||
Hand-rolled serializer/parser, zero deps. Plus all-day single-day UTC fix and
|
|
||||||
a widget R8 keep-rule crash fix
|
|
||||||
|
|
||||||
- [x] v2.7.1–v2.7.5 (2026-06-21) — launch crash fix (listener before grant),
|
|
||||||
user-controlled crash reporting, widget loading-spinner R8 keep rule, and
|
|
||||||
F-Droid reproducible-build cleanups for the official repo
|
|
||||||
|
|
||||||
- [~] release/v2.8.0 (not yet cut) — crash reports via the public Codeberg
|
|
||||||
tracker (MR !27) + reminder snooze/dismiss notification actions (MR !28,
|
|
||||||
snooze self-schedules an exact alarm; primary delivery stays provider-broadcast)
|
|
||||||
|
|
||||||
## Next
|
|
||||||
|
|
||||||
1. Cut **v2.8.0** from `release/v2.8.0` (bump versionName → tag via the
|
|
||||||
merge-driven pipeline) once on-device review signs off
|
|
||||||
2. **Attendee editing** — the committed next feature (Tier 5 #12, high-
|
|
||||||
importance, opened 2026-06-22). Attendees are already read & shown on the
|
|
||||||
detail screen since v0.6; the gap is the write side — make guests editable
|
|
||||||
in `EventEditScreen` / `EventForm` (add by typed email, role, remove),
|
|
||||||
persisted by diffing `CalendarContract.Attendees`. No new permission for the
|
|
||||||
typed-email path. **Invitation behavior DECIDED 2026-06-22: record-only on all
|
|
||||||
writable calendars** — Calendula never sends (no INTERNET); honest backend-aware
|
|
||||||
copy ("your account may email guests when it syncs" on synced calendars, "no one
|
|
||||||
is notified" on local). Full sketch in `ROADMAP.md` → "Attendee editing".
|
|
||||||
3. Then: the two INTERNET go/no-go calls (OSM autocomplete, remote calendar
|
|
||||||
create/edit) and Tier 2/3 leftovers (quick-settings tile, now-line, week
|
|
||||||
numbers in month, full-text search, accessibility pass). Drag-and-drop
|
|
||||||
rescheduling is **rejected**.
|
|
||||||
303
CHANGELOG.md
303
CHANGELOG.md
@@ -7,7 +7,253 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.17.1] — 2026-07-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Settings → Calendars now says what is different about a calendar instead of
|
||||||
|
leaving you to guess. Ones you can only view — a subscribed calendar, a
|
||||||
|
calendar shared with you read-only — are marked **Read-only** ([#76]).
|
||||||
|
- Calendars your device isn't syncing are marked **Not synced**, moved to the
|
||||||
|
bottom of their account and left without a switch. None of their events are on
|
||||||
|
the device, so the switch they used to have could not have shown you anything
|
||||||
|
— the calendar simply looked broken. They are no longer offered when you pick
|
||||||
|
a calendar for a new or an imported event either: an event saved there would
|
||||||
|
never reach the account. Whether an account syncs a calendar stays that
|
||||||
|
account's own app's decision ([#78]).
|
||||||
|
- The birthday and anniversary calendars Calendula fills from your contacts are
|
||||||
|
marked **Filled from your contacts**, which is why they can't be picked for a
|
||||||
|
new event: anything you put there would be removed again on the next sync.
|
||||||
|
Deleting one is held back while special dates are switched on — Calendula
|
||||||
|
would simply create it again — and the calendar's editor says so; turn the
|
||||||
|
feature off under Settings → Special dates and the delete works as usual
|
||||||
|
([#76]).
|
||||||
|
- The calendar picker in the event form and in the .ics import screen now ends
|
||||||
|
with a **"Missing a calendar?"** row that opens Settings → Calendars, where
|
||||||
|
those marks then explain why a calendar isn't offered ([#76]).
|
||||||
|
- The agenda widget's text size is yours to set. **Settings → Widgets & tiles →
|
||||||
|
Agenda widget size** offers Small, Medium, Large and Extra large, replacing the guess
|
||||||
|
the widget used to make from its own measurements. Small is what it looks like
|
||||||
|
today, so nothing changes until you turn it up ([#51]).
|
||||||
|
- A repeating event now shows you its next few dates, not just a description of
|
||||||
|
the rule. Both the preset list and the custom recurrence picker carry a
|
||||||
|
**Next:** line — "Next: 30 Jul, 6 Aug, 13 Aug" — under the rule they would
|
||||||
|
save. A phrase like "monthly" on the 31st, or "every 2 weeks on Mon & Fri",
|
||||||
|
can mean something other than it sounds like, and only the dates say so. A
|
||||||
|
rule that can never fire says that instead ([#69]).
|
||||||
|
- The event visibility options now say who they affect. **Public**, **Private**
|
||||||
|
and **Confidential** each carry a line about what other people on a shared
|
||||||
|
calendar see — the part the four words on their own leave out ([#69]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Settings has been reorganised so each setting sits where you would look for
|
||||||
|
it.** One long undifferentiated list is now three labelled groups — Look &
|
||||||
|
behaviour, Data, and App — whose rows open sub-screens and say what they do
|
||||||
|
rather than only naming themselves. Appearance, Views, New event form,
|
||||||
|
Notifications and the new Widgets & tiles are separate screens now, so the
|
||||||
|
settings for a calendar view are no longer mixed in with the ones for the
|
||||||
|
app's colours or for the home-screen widgets ([#69]).
|
||||||
|
- Settings that are hard to picture from their name now show you what they do.
|
||||||
|
The week-start picker rearranges a real month grid as you choose, the
|
||||||
|
past-events setting previews a sample agenda day for Show, Dim and Hide, the
|
||||||
|
font pickers set a specimen line in the face you are choosing, and the Agenda
|
||||||
|
range options carry the dates each one actually covers. Options that follow
|
||||||
|
the system additionally name which way they currently fall ([#69]).
|
||||||
|
- **Backup & restore** is now its own Settings entry instead of living inside
|
||||||
|
the calendar manager, where it was easy to miss — keeping a copy of your
|
||||||
|
calendars is a different question from which calendars you have. The calendar
|
||||||
|
manager keeps a row pointing to it, and nothing about how backup or automatic
|
||||||
|
backup works has changed ([#69]).
|
||||||
|
- Calendula's source code now lives on **Codeberg**, where its issues already
|
||||||
|
were. The **Source code** and **License** links in Settings → About point
|
||||||
|
there, so reporting a bug and reading the code no longer land on two different
|
||||||
|
sites. Nothing about the app itself changes, and the F-Droid repository is
|
||||||
|
unaffected.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- A month-grid widget stays a month grid, and draws all seven days again. Since
|
||||||
|
2.16.0 a placed month widget could redraw itself as the agenda widget a little
|
||||||
|
after any change to your events, and could draw only about four day columns
|
||||||
|
with the last one cut off part-way through. Both came from the release build
|
||||||
|
merging the two widgets into a single class, so Android could no longer tell
|
||||||
|
which of them a widget on your home screen was — and the month grid was handed
|
||||||
|
the wrong widget's measurements to lay its columns out against ([#89], [#103]).
|
||||||
|
- The back gesture on **Settings → Views** returns to Settings instead of
|
||||||
|
leaving Settings altogether and dropping you on the calendar. Special dates
|
||||||
|
did the same ([#81]).
|
||||||
|
- The dots standing in for the events that didn't fit a day in the month view
|
||||||
|
now dim with everything else when **Dim completed events** is on. A past day
|
||||||
|
with four or more events kept its last events at full strength while the rest
|
||||||
|
faded ([#79]).
|
||||||
|
- Two accounts that happen to share a name — a Google account and a DAVx5
|
||||||
|
account for the same address, say — are no longer merged into one group.
|
||||||
|
They were listed together in Settings → Calendars and in the drawer's filter,
|
||||||
|
which also meant the group's source icon and its "manage in app" button could
|
||||||
|
send you to the wrong app, "toggle all" spanned both accounts at once, and
|
||||||
|
collapsing one collapsed the other. Where a name really is shared, each group
|
||||||
|
now names the app it comes from ([#77]).
|
||||||
|
- In the month view's **Split** style, the new-event button now starts on the
|
||||||
|
day you have selected. It always started on today, whichever day was selected
|
||||||
|
and listed below the grid ([#87]).
|
||||||
|
- Search results now show an all-day event's real date. West of UTC — anywhere in
|
||||||
|
the Americas, say — a search hit was dated one day early, disagreeing with the
|
||||||
|
day the month, week and agenda views file the same event under ([#82]).
|
||||||
|
- Reminders no longer depend on Android telling Calendula when they are due.
|
||||||
|
Calendula now works out each reminder's time itself and sets its own alarm for
|
||||||
|
it. On some phones — Samsung's among them — the system's calendar storage never
|
||||||
|
sends the signal a calendar app is meant to wake up on, and no amount of
|
||||||
|
battery or notification settings helps: the reminder is simply never announced.
|
||||||
|
None of that is visible from inside an app that waits to be told, which is why
|
||||||
|
it took a second pass to find ([#75]).
|
||||||
|
|
||||||
|
Reminders also survive things that used to lose them quietly. After a restart
|
||||||
|
or an app update Calendula re-arms its alarms, and a reminder whose moment
|
||||||
|
passed while the phone was off still arrives, as long as the event has not
|
||||||
|
ended yet.
|
||||||
|
|
||||||
|
- All-day reminders now arrive at the time you chose in **Settings →
|
||||||
|
Notifications**, on every occurrence. A yearly birthday could drift an hour
|
||||||
|
either way depending on daylight saving, and all-day reminders on calendars
|
||||||
|
from an account fired in the middle of the night instead of in the morning
|
||||||
|
([#75]).
|
||||||
|
|
||||||
|
- Reminders now arrive for every calendar you have switched on. A calendar that
|
||||||
|
was hidden at system level — switched off in another calendar app, or never
|
||||||
|
switched on after being added — still showed its events and listed their
|
||||||
|
reminders in Calendula, but never notified: Android only schedules reminder
|
||||||
|
alarms for calendars marked visible, and Calendula kept its own separate
|
||||||
|
on/off list that had no say in it. There is now one switch: **Settings →
|
||||||
|
Calendars** turns a calendar on or off for the whole device, so what you see
|
||||||
|
and what reminds you can no longer disagree ([#75]).
|
||||||
|
|
||||||
|
Calendars you had switched off in Calendula are switched off here too on first
|
||||||
|
launch. Calendars that were already off — hidden in another calendar app, or
|
||||||
|
never switched on after being added — stay off, and Calendula says so once
|
||||||
|
rather than quietly switching them on for every app on your device; you can
|
||||||
|
turn any of them back on in Settings → Calendars.
|
||||||
|
|
||||||
|
If you gave Calendula read-only access to your calendars, the switch still
|
||||||
|
works: your choice is kept in the app until it can be written.
|
||||||
|
|
||||||
|
The drawer's filter is unchanged and still app-only: hiding a calendar there
|
||||||
|
tidies your view without silencing its reminders.
|
||||||
|
|
||||||
|
## [2.16.0] — 2026-07-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Choose how the month view is laid out. A new **Month view style** setting
|
||||||
|
(Settings → Views) offers three ways to read a month, each shown with a
|
||||||
|
preview of the layout it produces:
|
||||||
|
- **Pages** — what you have today: one month at a time, swiped sideways.
|
||||||
|
- **Continuous** — scroll up and down through the weeks without a break
|
||||||
|
between months. Because the weeks run on unbroken, no month is cut off and
|
||||||
|
no day appears twice, where paging repeats a boundary week at the end of one
|
||||||
|
month and the start of the next. The 1st of each month names itself so you
|
||||||
|
always know where you are, and the title bar keeps up as you scroll ([#38]).
|
||||||
|
- **Split** — a compact grid showing coloured dots for the days that have
|
||||||
|
something on them, with the day you tap listed in full underneath. Tap the
|
||||||
|
date above the list to open the whole day ([#53]).
|
||||||
|
|
||||||
|
The Agenda view is untouched by this and stays available in all three styles —
|
||||||
|
the split layout lists a single day, while Agenda remains a rolling multi-day
|
||||||
|
window with its own range settings.
|
||||||
|
- Give an event its own time zone. A new **Time zone** field (under "more
|
||||||
|
fields" in the event form) pins an event to a specific zone, so a call set for
|
||||||
|
8:00 AM in New York stays 8:00 AM in New York wherever you open it — and keeps
|
||||||
|
tracking that zone across daylight-saving changes instead of drifting an hour.
|
||||||
|
The form edits the event in its own zone and shows the local equivalent under
|
||||||
|
the times ("2:00 PM – 3:00 PM your time"); the event's details keep your local
|
||||||
|
time first and note the original beneath it, so both are always clear. Pick a
|
||||||
|
zone from a full-screen picker with your device zone and recent choices on top,
|
||||||
|
searching by city ("new york"), IANA id ("europe/berlin"), or abbreviation
|
||||||
|
("CEST") to gather every matching zone at once. All-day events stay
|
||||||
|
date-anchored and carry no zone, as before ([#31]).
|
||||||
|
- Put the "jump to today" button in the toolbar. A new **Today button in
|
||||||
|
toolbar** setting (Settings → Appearance, off by default) swaps the floating
|
||||||
|
button that fades into the corner while you're away from today for a permanent
|
||||||
|
today icon in the top bar — always there, on today or not, matching the
|
||||||
|
familiar calendar-app pattern. Leave it off to keep the floating button as
|
||||||
|
before ([#60]).
|
||||||
|
- Choose what Calendula calls itself on your home screen. A new **App name**
|
||||||
|
setting (Settings → Appearance) switches the launcher label between
|
||||||
|
**Calendula** and **Calendar**, for launchers that can't rename apps
|
||||||
|
themselves. Pick from a full-screen chooser that previews both names as
|
||||||
|
launcher marks; the change applies at once ([#44]).
|
||||||
|
- Calendula now speaks **Arabic**, laid out right-to-left, and its French and
|
||||||
|
Italian translations have been brought up to date — thanks to the community
|
||||||
|
translators on Weblate. Pick a language under Settings → Language (or leave it
|
||||||
|
on the system default).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Dates in the Month, Week and Day title bars now follow your language and
|
||||||
|
region instead of one hardcoded layout. Every date was rendered in a fixed
|
||||||
|
German-style order with a trailing dot on the day number, whatever your
|
||||||
|
settings: US English showed "Fri, 17. Jul 2026" where it should read
|
||||||
|
"Fri, Jul 17". The Agenda view already formatted correctly, so the two
|
||||||
|
disagreed about the same date. All four views now share one formatter, and the
|
||||||
|
day/month order, the separators and the ordinal all come from your locale —
|
||||||
|
so English-in-Germany reads "Fri, 17 Jul" and English-in-the-US "Fri, Jul 17",
|
||||||
|
each correct for where you are ([#60]).
|
||||||
|
- The title bar drops the year while you're in the current one — "July" rather
|
||||||
|
than "July 2026". The year reappears the moment you page out of the current
|
||||||
|
year, which is when it tells you something you didn't already know.
|
||||||
|
- The Week view's title now names the month instead of spelling out the day range.
|
||||||
|
"24. Jun – 31. Jun" restated the day numbers already printed in the column
|
||||||
|
headers right below it, in the widest string in the bar. A week that straddles
|
||||||
|
two months keeps the outgoing month until it is fully gone ([#60]).
|
||||||
|
- The custom recurrence picker has been redesigned and tightened up. As you
|
||||||
|
build a rule — "every 2 weeks on Mon & Wed, until a date" — the live summary
|
||||||
|
now describes exactly what will be saved rather than a near-copy that could
|
||||||
|
drift from it, the amount fields accept being left blank (reading as their
|
||||||
|
shown default instead of greying out OK), and the read-out no longer jumps
|
||||||
|
around as you tap weekdays ([#42]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- An all-day event no longer shows up again the day after it happened. In time
|
||||||
|
zones east of UTC, an all-day event — a birthday, say — set for one day also
|
||||||
|
appeared under the *next* day's heading in the Agenda (and the agenda widget),
|
||||||
|
because all-day events are anchored to UTC midnight and the following day's
|
||||||
|
window reached back across that boundary and pulled the event forward onto
|
||||||
|
"today". Each all-day event now lists only on the day it actually falls on
|
||||||
|
([#65]).
|
||||||
|
- A multi-day event now shows under every day it spans in the Agenda, not just
|
||||||
|
its first day, so a trip or a multi-day booking appears on each day it covers.
|
||||||
|
- The "Upcoming" agenda widget now scales its text and rows to the size you give
|
||||||
|
it. Previously it was laid out once for the smallest size and simply stretched
|
||||||
|
when enlarged, so the text stayed small no matter how big you made the widget.
|
||||||
|
Now a bigger widget gets bigger, more readable type and roomier rows, while the
|
||||||
|
default size looks exactly as before — no new setting; it follows the size you
|
||||||
|
already chose ([#51]).
|
||||||
|
- Calendula now appears under other apps' "Add to calendar" / "Save to calendar"
|
||||||
|
actions. Some apps (e.g. DB Navigator) fire the widely-used "insert event"
|
||||||
|
intent with the singular `vnd.android.cursor.item/event` type, which Calendula
|
||||||
|
didn't advertise — so it was left out of the chooser, and if it was your only
|
||||||
|
calendar app the save silently did nothing. It now accepts that form, plus the
|
||||||
|
`INSERT_OR_EDIT` action, and opens the new event prefilled for review ([#74]).
|
||||||
|
- Opening a `.ics`/`.vcs` file now works even when another app hands it over
|
||||||
|
mislabelled as a generic download (`application/octet-stream`), as some mail
|
||||||
|
clients, browsers and file managers do — Calendula recognises it by its file
|
||||||
|
extension instead of relying on the declared type ([#74]).
|
||||||
|
- A recurrence end date no longer lands a day late. West of UTC, setting a rule
|
||||||
|
to end "until" a given day could save and show the day after the one picked;
|
||||||
|
the end date now reads back as chosen ([#42]).
|
||||||
|
- The status- and navigation-bar icons stay legible over full-screen pickers in
|
||||||
|
dark theme. They could render dark-on-dark — a near-invisible black clock
|
||||||
|
against the dark picker — instead of switching to light ([#70]).
|
||||||
|
|
||||||
|
## [2.15.0] — 2026-07-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Show raw calendar colours. Calendula normally softens each calendar and event
|
||||||
|
colour toward a theme-fitting pastel so harsh sync colours read well on both
|
||||||
|
light and dark; a new **Soften calendar colours** setting (Settings → Design,
|
||||||
|
on by default) lets you turn that off and paint the exact colours your calendar
|
||||||
|
source publishes — matching DAVx5/CalDAV and other calendar apps. Thanks to
|
||||||
|
@leonp5 for the report ([#36]).
|
||||||
|
- Readable titles on dark event colours. An event bar's title now shows in white
|
||||||
|
on a dark colour and near-black on a light one, chosen automatically from the
|
||||||
|
colour's brightness, so a deep blue or purple event is legible at a glance in
|
||||||
|
the busy Week and Month views instead of dark-on-dark. This applies whether or
|
||||||
|
not colours are softened. Thanks to @ptab for the suggestion ([#21]).
|
||||||
- A custom snooze duration. The **Snooze duration** setting (Settings →
|
- A custom snooze duration. The **Snooze duration** setting (Settings →
|
||||||
Notifications) gains a **Custom…** option next to the minute presets: pick any
|
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
|
amount and switch between minutes and hours, so a snoozed reminder comes back
|
||||||
@@ -29,6 +275,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
a couple more file labels the same calendar data arrives under (`.vcs`
|
a couple more file labels the same calendar data arrives under (`.vcs`
|
||||||
vCalendar files and the `application/ics` type), so opening or sharing those
|
vCalendar files and the `application/ics` type), so opening or sharing those
|
||||||
into Calendula works too.
|
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
|
### Fixed
|
||||||
- Reminders for events on another day no longer read as if they were today. A
|
- Reminders for events on another day no longer read as if they were today. A
|
||||||
@@ -37,6 +305,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
|
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
|
week, or the date for anything further out. Thanks to @moonj for the report
|
||||||
([#46]).
|
([#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
|
## [2.14.1] — 2026-07-13
|
||||||
|
|
||||||
@@ -939,14 +1217,39 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25
|
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25
|
||||||
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27
|
[#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
|
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
|
||||||
|
[#31]: https://codeberg.org/jlmakiola/calendula/issues/31
|
||||||
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
|
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
|
||||||
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
||||||
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||||
|
[#36]: https://codeberg.org/jlmakiola/calendula/issues/36
|
||||||
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
||||||
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
|
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
|
||||||
|
[#35]: https://codeberg.org/jlmakiola/calendula/issues/35
|
||||||
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
|
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
|
||||||
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
|
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
|
||||||
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
||||||
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
||||||
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
||||||
|
[#51]: https://codeberg.org/jlmakiola/calendula/issues/51
|
||||||
|
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
|
||||||
|
[#38]: https://codeberg.org/jlmakiola/calendula/issues/38
|
||||||
|
[#53]: https://codeberg.org/jlmakiola/calendula/issues/53
|
||||||
|
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60
|
||||||
|
[#65]: https://codeberg.org/jlmakiola/calendula/issues/65
|
||||||
|
[#74]: https://codeberg.org/jlmakiola/calendula/issues/74
|
||||||
|
[#42]: https://codeberg.org/jlmakiola/calendula/issues/42
|
||||||
|
[#44]: https://codeberg.org/jlmakiola/calendula/issues/44
|
||||||
|
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70
|
||||||
|
[#75]: https://codeberg.org/jlmakiola/calendula/issues/75
|
||||||
|
[#76]: https://codeberg.org/jlmakiola/calendula/issues/76
|
||||||
|
[#78]: https://codeberg.org/jlmakiola/calendula/issues/78
|
||||||
|
[#77]: https://codeberg.org/jlmakiola/calendula/issues/77
|
||||||
|
[#79]: https://codeberg.org/jlmakiola/calendula/issues/79
|
||||||
|
[#81]: https://codeberg.org/jlmakiola/calendula/issues/81
|
||||||
|
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
||||||
|
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
|
||||||
|
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
|
||||||
|
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
||||||
|
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69
|
||||||
|
|||||||
184
CONTRIBUTING.md
Normal file
184
CONTRIBUTING.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# Contributing to Calendula
|
||||||
|
|
||||||
|
Calendula is a Material 3 Expressive calendar app that lives strictly on top of
|
||||||
|
Android's `CalendarContract` — no app database, no sync stack, no network access.
|
||||||
|
That constraint shapes most review comments, so
|
||||||
|
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) is worth skimming before you write
|
||||||
|
code. This file is the practical how.
|
||||||
|
|
||||||
|
**[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home** —
|
||||||
|
issues, pull requests, releases. The self-hosted Gitea instance referenced in the
|
||||||
|
release docs is build infrastructure only; there is nothing to contribute there.
|
||||||
|
Be decent to the people you meet in the tracker.
|
||||||
|
|
||||||
|
## Start with an issue
|
||||||
|
|
||||||
|
| You want to | Do this |
|
||||||
|
|---|---|
|
||||||
|
| Add a feature | **Open an issue first** and wait for a go-ahead |
|
||||||
|
| Fix a bug | Open an issue, then a pull request |
|
||||||
|
| Fix a typo, a comment, or docs | Just open the pull request |
|
||||||
|
| Add or fix a translation | **Don't** — [use Weblate](#translations) |
|
||||||
|
|
||||||
|
Features get an opinion before they get code: whether Calendula should do a
|
||||||
|
thing at all is the one decision a patch can't make. A feature PR that arrives
|
||||||
|
without a discussed issue may be closed unmerged even when the code is good —
|
||||||
|
please don't spend a weekend on one first.
|
||||||
|
|
||||||
|
Bugs are more straightforward, but still start with an issue: it's what carries
|
||||||
|
the milestone and gives the changelog something to link.
|
||||||
|
|
||||||
|
Issue templates cover bug, crash, feature and question. For a crash, let the app
|
||||||
|
do the work — **Settings → Report a problem**, or the prompt shown after a crash,
|
||||||
|
captures the stack trace and prefills the form. The report contains app, Android
|
||||||
|
and device versions plus the trace; no calendar content, no personal data.
|
||||||
|
|
||||||
|
## Which branch to target
|
||||||
|
|
||||||
|
Calendula releases by merging a version bump into `main`, so `main` is a release
|
||||||
|
trigger rather than a staging area. Work is assembled on release branches first.
|
||||||
|
|
||||||
|
Once your issue has a milestone, that milestone names your branch:
|
||||||
|
|
||||||
|
| Milestone | Target branch |
|
||||||
|
|---|---|
|
||||||
|
| `2.18.0` | `release/v2.18.0` |
|
||||||
|
|
||||||
|
Every milestone has a matching branch. If it's somehow missing, target `main` and
|
||||||
|
mention it in the PR — it will be retargeted. Don't pick an older release branch:
|
||||||
|
they're kept after shipping, so the newest one isn't necessarily yours.
|
||||||
|
|
||||||
|
## Translations
|
||||||
|
|
||||||
|
**Never edit a `values-*/strings.xml` file in a pull request** — German included.
|
||||||
|
Translations are owned by a self-hosted Weblate that writes to this repository
|
||||||
|
directly, and a hand-edit is overwritten on the next sync.
|
||||||
|
|
||||||
|
→ **[Translate Calendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
|
||||||
|
|
||||||
|
Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
|
||||||
|
picks it up and offers it to translators. Partial translations are expected and
|
||||||
|
fine — missing keys are informational. Stale and orphaned keys are not, so run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/check_translations.py
|
||||||
|
```
|
||||||
|
|
||||||
|
before pushing. It reports those more clearly than lint's `MissingTranslation`
|
||||||
|
does.
|
||||||
|
|
||||||
|
## Build & test
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git
|
||||||
|
```
|
||||||
|
|
||||||
|
The `floret-kit` submodule is a composite build compiled from source. An
|
||||||
|
existing clone needs `git submodule update --init --recursive`, or nothing
|
||||||
|
resolves.
|
||||||
|
|
||||||
|
- **JDK 17** — not newer; the Android Gradle Plugin requires exactly 17. Set
|
||||||
|
`JAVA_HOME` if your default differs.
|
||||||
|
- **Android SDK** — platform 37 (`compileSdk`) and build-tools 36.0.0, located
|
||||||
|
via `ANDROID_HOME` or a gitignored `local.properties` with `sdk.dir`. If you
|
||||||
|
go the `local.properties` route the included build needs its own copy at
|
||||||
|
`floret-kit/local.properties`; `ANDROID_HOME` covers both at once and is the
|
||||||
|
easier path.
|
||||||
|
|
||||||
|
The Gradle wrapper is checked in, so no system Gradle is needed.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./gradlew lint test assembleDebug # roughly what CI runs
|
||||||
|
```
|
||||||
|
|
||||||
|
A single test class, or a pattern:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./gradlew testDebugUnitTest --tests "de.jeanlucmakiola.calendula.domain.SimpleRecurrenceTest"
|
||||||
|
./gradlew testDebugUnitTest --tests "*SimpleRecurrence*"
|
||||||
|
```
|
||||||
|
|
||||||
|
CI reports one `CI` check per pull request: `lintDebug`, `testDebugUnitTest`,
|
||||||
|
`assembleDebug`, and a Trivy scan. Pull requests touching only docs, F-Droid
|
||||||
|
metadata or the licence skip the Android build and go green quickly. More detail
|
||||||
|
in [`docs/BUILDING.md`](docs/BUILDING.md).
|
||||||
|
|
||||||
|
## The rules
|
||||||
|
|
||||||
|
These are the ones that turn into review comments.
|
||||||
|
|
||||||
|
1. **No network.** Calendula holds no `INTERNET` permission, and that's a
|
||||||
|
feature rather than an oversight. Anything that would need one is a product
|
||||||
|
decision before it's a patch — the crash reporter deliberately opens a
|
||||||
|
prefilled web issue instead of posting anything itself.
|
||||||
|
2. **The provider is the only database.** No Room, no cache, no local mirror of
|
||||||
|
events. `CalendarContract` is the single source of truth, which is also why
|
||||||
|
externally synced changes work for free.
|
||||||
|
3. **Don't patch UI state after a write.** A `ContentObserver` re-queries and
|
||||||
|
views recompose from fresh provider state. Hand-patching a list after saving
|
||||||
|
appears to work, then quietly diverges from what the provider actually stored.
|
||||||
|
4. **`domain/` has no Android imports.** Models, validation, recurrence
|
||||||
|
rendering, conflict snapshots and the `.ics` codec stay pure Kotlin so they
|
||||||
|
remain JVM-testable.
|
||||||
|
5. **Tests run on the JVM.** JUnit 5 + Truth + Turbine. The seams exist for you:
|
||||||
|
fake the data source (`FakeCalendarDataSource`), and feed mappers plain maps
|
||||||
|
through `ColumnReader` instead of cursors. Instrumented tests are a last
|
||||||
|
resort, not a default.
|
||||||
|
6. **Read before touching the subtle pipelines.** Recurring writes (UNTIL vs
|
||||||
|
DURATION, exception URIs, series splits), save-conflict detection and reminder
|
||||||
|
delivery (post-before-mark) follow provider-driven rules that are documented
|
||||||
|
in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) and are not guessable from
|
||||||
|
the code alone.
|
||||||
|
7. **Don't break reproducible builds.** `vcsInfo`, `dependenciesInfo` and the AGP
|
||||||
|
metadata block are disabled on purpose so the official F-Droid repo can verify
|
||||||
|
our binary against a from-source rebuild.
|
||||||
|
`scripts/check_reproducible_release.sh` runs on every pull request, including
|
||||||
|
docs-only ones.
|
||||||
|
|
||||||
|
## UI conventions
|
||||||
|
|
||||||
|
Material 3 Expressive throughout, built from the system's own tokens and
|
||||||
|
components — colour-scheme tokens rather than hardcoded colours, `ListItem` for
|
||||||
|
settings rows.
|
||||||
|
|
||||||
|
**Selection pickers are full-screen.** Every browse-style "choose one" surface
|
||||||
|
uses floret-kit's `FullScreenPicker` / `OptionPicker`; one that needs a commit or
|
||||||
|
extra action passes it through the picker's `actions` slot. The exception is the
|
||||||
|
recurring-scope chooser (*this / this and following / all*), which stays a
|
||||||
|
compact dialog — a two- or three-option decision reads better as a popup than as
|
||||||
|
a nearly empty screen. `AlertDialog` is for plain confirmations only, and radio-
|
||||||
|
or text-list dialogs aren't used at all.
|
||||||
|
|
||||||
|
Shared UI machinery lives in the `floret-kit` submodule and has
|
||||||
|
[its own contributing guide](https://codeberg.org/jlmakiola/floret-kit/src/branch/main/CONTRIBUTING.md);
|
||||||
|
changing it means a pull request against that repository plus a submodule bump
|
||||||
|
here.
|
||||||
|
|
||||||
|
## Commits & pull requests
|
||||||
|
|
||||||
|
Conventional commits, scoped to the area you touched:
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(calendars): keep an event's own calendar when it is switched off
|
||||||
|
feat(month): pull-to-expand the split view (#38)
|
||||||
|
docs(architecture): record what the second review pass changed
|
||||||
|
```
|
||||||
|
|
||||||
|
Types in use: `feat` `fix` `docs` `refactor` `style` `chore` `ci` `build`
|
||||||
|
`revert`. Reference the issue in the subject or the body. Keep commits small —
|
||||||
|
small commits revert cleanly, which matters more here than a tidy history.
|
||||||
|
|
||||||
|
If your change is user-visible, add an entry under `## [Unreleased]` in
|
||||||
|
[`CHANGELOG.md`](CHANGELOG.md). Match the surrounding voice: entries describe
|
||||||
|
what changed *for the person using the app*, and why, not what changed in the
|
||||||
|
code. Link the issue and add its reference at the bottom of the file. It may get
|
||||||
|
reworded when the release is cut, so don't agonise over it.
|
||||||
|
|
||||||
|
Please don't commit planning or design documents. Code, tests, architecture notes
|
||||||
|
and the changelog land; the reasoning belongs in the commit message and the
|
||||||
|
issue.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
Calendula is [MIT](LICENSE). By contributing you agree your changes ship under
|
||||||
|
the same licence.
|
||||||
10
Gemfile
Normal file
10
Gemfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
# fastlane is used ONLY to upload the release bundle to Google Play
|
||||||
|
# (see fastlane/Fastfile). It is not part of the build or the signing path, so
|
||||||
|
# it never runs on a PR — only in release.yaml's `play` job.
|
||||||
|
#
|
||||||
|
# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is
|
||||||
|
# committed on purpose: this resolves an uploader's transitive deps, not the
|
||||||
|
# app's, and none of it affects the reproducible release build.
|
||||||
|
gem "fastlane", "2.237.0"
|
||||||
103
README.md
103
README.md
@@ -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">
|
||||||
@@ -18,15 +18,18 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
|
|||||||
<p>
|
<p>
|
||||||
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
|
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
|
||||||
|
|
||||||
|
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
|
||||||
|
|
||||||
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
|
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="19%" alt="Week view">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="16%" alt="Week view">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="19%" alt="Month view">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="16%" alt="Month view">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-day.png" width="16%" alt="Day view">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-edit.png" width="19%" alt="Event form">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="16%" alt="Event detail">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-agenda.png" width="16%" alt="Agenda view">
|
||||||
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="16%" alt="Reminder onboarding">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -68,24 +71,38 @@ database, no sync stack reinvented.
|
|||||||
|
|
||||||
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
|
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
|
||||||
expressive motion and shapes, light/dark theme
|
expressive motion and shapes, light/dark theme
|
||||||
- German and English UI, per-app language setting — and [open to community
|
- English and German UI plus community translations (Spanish, French, Italian,
|
||||||
translations](#-translations)
|
Polish, and more in progress), per-app language setting — and [open to more
|
||||||
|
languages](#-translations)
|
||||||
- **Zero telemetry, zero analytics, no internet permission** — your data
|
- **Zero telemetry, zero analytics, no internet permission** — your data
|
||||||
never leaves the device
|
never leaves the device
|
||||||
|
|
||||||
## 📦 Install
|
## 📦 Install
|
||||||
|
|
||||||
|
Pick whichever channel you already use — they all install the same app:
|
||||||
|
|
||||||
|
| Channel | Updates | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup |
|
||||||
|
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; needs the repo added once |
|
||||||
|
| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automated by Obtainium |
|
||||||
|
| [Google Play](#google-play-coming-soon) | — | Coming soon |
|
||||||
|
| [Build from source](#build-from-source) | Whenever you build | Full control |
|
||||||
|
|
||||||
### F-Droid (recommended)
|
### F-Droid (recommended)
|
||||||
|
|
||||||
Calendula is on the **official [F-Droid](https://f-droid.org) repository** —
|
Calendula is on the **official [F-Droid](https://f-droid.org) repository** —
|
||||||
just search for **Calendula** in any F-Droid client, or
|
just search for **Calendula** in any F-Droid client, or
|
||||||
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
|
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
|
||||||
|
|
||||||
### Self-hosted repo (latest builds)
|
F-Droid rebuilds from source on its own schedule, so a new version usually
|
||||||
|
shows up there a few days after release.
|
||||||
|
|
||||||
New versions are built, signed, and published to a self-hosted repository the
|
### Self-hosted F-Droid repo (fastest updates)
|
||||||
moment each tag lands — usually a few days ahead of the official repo, which
|
|
||||||
rebuilds on F-Droid's own schedule. Add it for the freshest builds:
|
Every release is built, signed, and published to a self-hosted F-Droid
|
||||||
|
repository as part of the release pipeline, so it lands there first. Add it once
|
||||||
|
and your F-Droid client handles updates from then on:
|
||||||
|
|
||||||
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
|
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
|
||||||
link below on your phone):
|
link below on your phone):
|
||||||
@@ -100,19 +117,71 @@ rebuilds on F-Droid's own schedule. Add it for the freshest builds:
|
|||||||
|
|
||||||
2. Refresh, search for **Calendula**, install.
|
2. Refresh, search for **Calendula**, install.
|
||||||
|
|
||||||
Both channels share the same signing key, so you can switch between them
|
### Codeberg release / Obtainium
|
||||||
without reinstalling. Or build from source — see below.
|
|
||||||
|
If you'd rather not use F-Droid at all, every release is also published on
|
||||||
|
**[Codeberg](https://codeberg.org/jlmakiola/calendula/releases)** with the
|
||||||
|
signed APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum attached — download
|
||||||
|
and install it directly.
|
||||||
|
|
||||||
|
For automatic updates from that channel, use
|
||||||
|
**[Obtainium](https://github.com/ImranR98/Obtainium)** — on the phone,
|
||||||
|
**[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula)**,
|
||||||
|
or do it by hand: *Add App* → paste `https://codeberg.org/jlmakiola/calendula`
|
||||||
|
→ *Add*. Either way, Obtainium tracks the releases and prompts you when a new
|
||||||
|
one appears.
|
||||||
|
|
||||||
|
### Google Play (coming soon)
|
||||||
|
|
||||||
|
Calendula is on its way to Google Play as an additional channel. It isn't live
|
||||||
|
yet — this section gets a link once it is. Play builds will be signed with
|
||||||
|
Google's key rather than mine, so switching between Play and any other channel
|
||||||
|
will require an uninstall.
|
||||||
|
|
||||||
|
> **Testers wanted.** Play requires a round of closed testing before the app can
|
||||||
|
> go public, and I'm still looking for testers. If you'd like to help, email
|
||||||
|
> **[business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de)** with the
|
||||||
|
> Google account address you want to use — that address is what I need to add you
|
||||||
|
> to the closed test.
|
||||||
|
|
||||||
|
### Build from source
|
||||||
|
|
||||||
|
The build is a plain Gradle build with no proprietary dependencies — see
|
||||||
|
**[docs/BUILDING.md](docs/BUILDING.md)** (note the `floret-kit` submodule).
|
||||||
|
|
||||||
|
<sub>Official F-Droid, the self-hosted repo, and the Codeberg releases all share
|
||||||
|
the same signing key, so you can switch freely between them without
|
||||||
|
reinstalling.</sub>
|
||||||
|
|
||||||
## 📚 Documentation
|
## 📚 Documentation
|
||||||
|
|
||||||
|
- **[Contributing](CONTRIBUTING.md)** — how to report, propose, and patch
|
||||||
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
|
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
|
||||||
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
||||||
- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next
|
- **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Bug reports, ideas, and patches are all welcome on
|
||||||
|
**[Codeberg](https://codeberg.org/jlmakiola/calendula/issues)**.
|
||||||
|
|
||||||
|
The short version: **start with an issue.** Features get a yes-or-no before they
|
||||||
|
get code, and both features and bugs are assigned a milestone whose
|
||||||
|
`release/vX.Y.Z` branch your pull request then targets. Typo and docs fixes can
|
||||||
|
skip straight to a pull request. Translations don't go through pull requests at
|
||||||
|
all — [Weblate owns them](#-translations).
|
||||||
|
|
||||||
|
Read **[CONTRIBUTING.md](CONTRIBUTING.md)** before writing code: it covers the
|
||||||
|
workflow, the build (note the `floret-kit` submodule), and the architectural
|
||||||
|
rules a change is reviewed against.
|
||||||
|
|
||||||
## 🌍 Translations
|
## 🌍 Translations
|
||||||
|
|
||||||
Calendula ships in German and English, and you're warmly invited to add your
|
Calendula ships in English and German, with community translations in Arabic,
|
||||||
language. Translations are managed on a self-hosted **Weblate**:
|
Chinese, French, Italian, Polish, Portuguese, Russian, and Spanish at varying
|
||||||
|
degrees of completeness — partial is fine, untranslated strings simply fall back
|
||||||
|
to English. You're warmly invited to add or finish your language. Translations
|
||||||
|
are managed on a self-hosted **Weblate**:
|
||||||
|
|
||||||
**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
|
**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
|
||||||
|
|
||||||
|
|||||||
@@ -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 = 21401
|
versionCode = 21701
|
||||||
versionName = "2.14.1"
|
versionName = "2.17.1"
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
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
|
||||||
@@ -113,6 +120,28 @@
|
|||||||
<data android:mimeType="text/x-vcalendar" />
|
<data android:mimeType="text/x-vcalendar" />
|
||||||
<data android:mimeType="application/ics" />
|
<data android:mimeType="application/ics" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- 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). -->
|
<!-- 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" />
|
||||||
@@ -124,11 +153,13 @@
|
|||||||
|
|
||||||
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
|
<!-- 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:
|
to create a new event, the way the AOSP calendar accepts it:
|
||||||
ACTION_INSERT on the events dir mime type, carrying the new
|
ACTION_INSERT on the events *dir* mime type, carrying the new
|
||||||
event's fields as CalendarContract extras
|
event's fields as CalendarContract extras
|
||||||
(MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
|
(MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
|
||||||
dir mime is AOSP's "edit a new event" — i.e. create — so it maps
|
dir mime is AOSP's "edit a new event" — i.e. create — so it maps
|
||||||
to the same prefilled create form. -->
|
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>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.INSERT" />
|
<action android:name="android.intent.action.INSERT" />
|
||||||
<action android:name="android.intent.action.EDIT" />
|
<action android:name="android.intent.action.EDIT" />
|
||||||
@@ -136,14 +167,31 @@
|
|||||||
<data android:mimeType="vnd.android.cursor.dir/event" />
|
<data android:mimeType="vnd.android.cursor.dir/event" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Edit an existing event another app/assistant/widget points at:
|
<!-- Create or edit an event another app/assistant/widget points at,
|
||||||
ACTION_EDIT on content://com.android.calendar/events/<id>, the way
|
addressed by the provider's *item* MIME type. Three actions share
|
||||||
AOSP fires it. Opens the occurrence in the edit form (not the
|
this filter, told apart at runtime by the intent's data:
|
||||||
read-only detail — that's the VIEW filter above). Matched by the
|
|
||||||
provider's item MIME type, like the VIEW filter. The occurrence's
|
• ACTION_INSERT — create. This is the form the Android docs'
|
||||||
times ride as EXTRA_EVENT_BEGIN_TIME / EXTRA_EVENT_END_TIME when
|
"insert an event" example and many apps use
|
||||||
supplied (MainActivity.editEventKeyOrNull). -->
|
(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>
|
<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" />
|
<action android:name="android.intent.action.EDIT" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
<data android:mimeType="vnd.android.cursor.item/event" />
|
||||||
@@ -165,11 +213,47 @@
|
|||||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
<data android:mimeType="vnd.android.cursor.item/event" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
</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
|
||||||
@@ -194,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,9 +4,12 @@ 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.reminders.ReminderMaintenanceScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
|
||||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -39,6 +42,37 @@ class CalendulaApp : Application() {
|
|||||||
)
|
)
|
||||||
reconcileAutoBackup()
|
reconcileAutoBackup()
|
||||||
reconcileSpecialDates()
|
reconcileSpecialDates()
|
||||||
|
reconcileCalendarVisibility()
|
||||||
|
startReminderDelivery()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring reminder delivery up with the process (#75): a scan re-arms whatever
|
||||||
|
* the system dropped and posts what a missed alarm still owes, then the
|
||||||
|
* provider watch keeps edits re-planned. The daily worker is the backstop.
|
||||||
|
*/
|
||||||
|
private fun startReminderDelivery() {
|
||||||
|
val deps = EntryPointAccessors.fromApplication(
|
||||||
|
this, ReminderMaintenanceWorker.Deps::class.java,
|
||||||
|
)
|
||||||
|
val scanner = deps.reminderScanner()
|
||||||
|
scanner.startWatchingProvider()
|
||||||
|
scanner.scanInBackground()
|
||||||
|
ReminderMaintenanceScheduler.apply(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush any calendar switch-off not yet written to `Calendars.VISIBLE`,
|
||||||
|
* including the set inherited from the retired app-local model (#75). A
|
||||||
|
* no-op in the steady state; `RootScreen` re-runs it after a later grant.
|
||||||
|
*/
|
||||||
|
private fun reconcileCalendarVisibility() {
|
||||||
|
val deps = EntryPointAccessors.fromApplication(
|
||||||
|
this, CalendarVisibilityReconciler.Deps::class.java,
|
||||||
|
)
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||||
|
deps.calendarVisibilityReconciler().run()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,10 +95,8 @@ class CalendulaApp : Application() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-arm (or cancel) the daily special-dates reconcile from the saved
|
* Re-arm (or cancel) the daily special-dates reconcile from the saved
|
||||||
* settings on every launch — like [reconcileAutoBackup], this cancels
|
* settings, like [reconcileAutoBackup]. The on-open refresh is RootScreen's
|
||||||
* orphaned work after the feature is turned off and re-schedules after a
|
* ON_RESUME trigger, so no immediate run is needed here.
|
||||||
* reinstall. The foreground trigger (RootScreen ON_RESUME) does the on-open
|
|
||||||
* refresh, so no immediate run is needed here.
|
|
||||||
*/
|
*/
|
||||||
private fun reconcileSpecialDates() {
|
private fun reconcileSpecialDates() {
|
||||||
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)
|
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
|||||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
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
|
||||||
@@ -140,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(),
|
||||||
@@ -220,10 +222,12 @@ class MainActivity : AppCompatActivity() {
|
|||||||
* [buildInsertEventForm].
|
* [buildInsertEventForm].
|
||||||
*/
|
*/
|
||||||
private fun Intent.insertFormOrNull(): EventForm? {
|
private fun Intent.insertFormOrNull(): EventForm? {
|
||||||
// ACTION_EDIT on an existing event routes to the edit form instead
|
// ACTION_EDIT / ACTION_INSERT_OR_EDIT on an existing event route to the
|
||||||
// ([editEventKeyOrNull]); only an id-less EDIT is a create.
|
// edit form instead ([editEventKeyOrNull]); an id-less one is a create,
|
||||||
|
// as is any plain ACTION_INSERT.
|
||||||
val isCreate = action == Intent.ACTION_INSERT ||
|
val isCreate = action == Intent.ACTION_INSERT ||
|
||||||
(action == Intent.ACTION_EDIT && editEventKeyOrNull() == null)
|
((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) &&
|
||||||
|
editEventKeyOrNull() == null)
|
||||||
if (!isCreate) return null
|
if (!isCreate) return null
|
||||||
return buildInsertEventForm(
|
return buildInsertEventForm(
|
||||||
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
||||||
@@ -342,10 +346,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME`
|
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME`
|
||||||
* when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and
|
* when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and
|
||||||
* [EventEditViewModel.openForEdit] falls back to the event row's own
|
* [EventEditViewModel.openForEdit] falls back to the event row's own
|
||||||
* DTSTART/DTEND. An id-less `ACTION_EDIT` is a create instead ([insertFormOrNull]).
|
* 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? {
|
private fun Intent.editEventKeyOrNull(): LongArray? {
|
||||||
if (action != Intent.ACTION_EDIT) return null
|
if (action != Intent.ACTION_EDIT && action != Intent.ACTION_INSERT_OR_EDIT) return null
|
||||||
val uri = data ?: return null
|
val uri = data ?: return null
|
||||||
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||||
val segments = uri.pathSegments
|
val segments = uri.pathSegments
|
||||||
|
|||||||
@@ -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,13 @@ 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 the day displayed is the day
|
||||||
* row — including pre-feature rows (raw multiples of 1440, fired at UTC midnight)
|
* the reminder actually fires on.
|
||||||
* and rows written under a different timezone. A negative [rawMinutes] (fire
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -54,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
|
||||||
@@ -110,6 +115,28 @@ 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), which also gates its reminders. One of
|
||||||
|
* the three columns the platform documents as app-writable, and device-local.
|
||||||
|
*/
|
||||||
|
fun setCalendarVisible(id: Long, visible: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether one calendar is switched on at system level, without reading every
|
||||||
|
* row — for the reminder gate, which sees only a calendar id. Null when the
|
||||||
|
* answer can't be had: no row, or no read permission.
|
||||||
|
*/
|
||||||
|
fun isCalendarVisible(id: Long): Boolean?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the app holds `WRITE_CALENDAR`, i.e. may write
|
||||||
|
* [setCalendarVisible] at all. Read-only users (READ granted, WRITE denied)
|
||||||
|
* keep their calendar switches app-side instead — see
|
||||||
|
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds].
|
||||||
|
*/
|
||||||
|
fun canWriteCalendars(): Boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a local calendar tagged as the special-dates mirror for [type]
|
* 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
|
||||||
@@ -373,6 +400,37 @@ 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` skips the dirty marking and the
|
||||||
|
* `checkNextAlarm()` reschedule unless the selection is `_id=…`, and the
|
||||||
|
* plain URI is what makes the write apply to synced calendars too.
|
||||||
|
*/
|
||||||
|
override fun setCalendarVisible(id: Long, visible: Boolean) {
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Calendars.VISIBLE, if (visible) 1 else 0)
|
||||||
|
}
|
||||||
|
val rows = resolver.update(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, id),
|
||||||
|
values, null, null,
|
||||||
|
)
|
||||||
|
if (rows == 0) throw WriteFailedException("set calendar visibility id=$id")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isCalendarVisible(id: Long): Boolean? {
|
||||||
|
if (!hasCalendarPermission()) return null
|
||||||
|
return resolver.query(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, id),
|
||||||
|
arrayOf(CalendarContract.Calendars.VISIBLE),
|
||||||
|
null, null, null,
|
||||||
|
)?.use { if (it.moveToFirst()) it.getInt(0) != 0 else null }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun canWriteCalendars(): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
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 {
|
||||||
@@ -610,7 +668,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun eventDetail(eventId: Long): EventDetail? {
|
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? {
|
||||||
val attendees = queryAttendees(eventId)
|
val attendees = queryAttendees(eventId)
|
||||||
val reminders = queryReminders(eventId)
|
val reminders = queryReminders(eventId)
|
||||||
return resolver.query(
|
return resolver.query(
|
||||||
@@ -619,7 +677,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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,5 +31,8 @@ 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,
|
||||||
|
// NULL is treated as syncing — the harmless default.
|
||||||
|
syncsEvents = isNull(CalendarProjection.IDX_SYNC_EVENTS) ||
|
||||||
|
getInt(CalendarProjection.IDX_SYNC_EVENTS) != 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ 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 also gates
|
||||||
|
* their reminders — see [CalendarDataSource.setCalendarVisible]. Written one
|
||||||
|
* at a time, in order; a failure part-way leaves the earlier writes standing.
|
||||||
|
*
|
||||||
|
* Without `WRITE_CALENDAR` the choice is kept app-side instead (see
|
||||||
|
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds]).
|
||||||
|
*/
|
||||||
|
suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every event of the writable local calendars, ready to serialise into a
|
* 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]).
|
||||||
|
|||||||
@@ -16,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
|
||||||
@@ -47,52 +53,120 @@ 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.
|
||||||
.reQuery { dataSource.calendars() }
|
*/
|
||||||
.flowOn(io)
|
private fun visibilityTicks(): Flow<Unit> = merge(
|
||||||
|
ticks.onStart { emit(Unit) },
|
||||||
|
// drop(1): the current value is already covered by the tick above.
|
||||||
|
prefs.pendingDisabledCalendarIds.drop(1).map {},
|
||||||
|
)
|
||||||
|
|
||||||
// Instances are filtered by the app-side hidden ∪ disabled calendar sets
|
// A switch-off not yet written to the provider is folded into the flag
|
||||||
// (M3): an event is dropped whenever the user has hidden *or* disabled its
|
// itself, so every consumer reads one visibility (#75). The reconciler goes
|
||||||
// calendar. Re-runs when the provider ticks *or* either set changes —
|
// to the data source directly — it needs the provider's own answer.
|
||||||
// toggling a calendar in the filter sheet or the calendar manager updates
|
override fun calendars(): Flow<List<CalendarSource>> =
|
||||||
// every view immediately. [calendars] stays unfiltered so those screens can
|
visibilityTicks().reQuery {
|
||||||
// list and re-enable hidden/disabled calendars.
|
val calendars = calendarsSnapshot()
|
||||||
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
val pendingDisabled = prefs.pendingDisabledCalendarIds.first()
|
||||||
combine(
|
if (pendingDisabled.isEmpty()) calendars
|
||||||
ticks
|
else calendars.map {
|
||||||
.onStart { emit(Unit) }
|
if (it.id in pendingDisabled) it.copy(isVisibleInSystem = false) else it
|
||||||
.reQuery {
|
}
|
||||||
dataSource.instances(
|
|
||||||
beginMillis = range.start.toEpochMillis(),
|
|
||||||
endMillis = range.endInclusive.toEpochMillis(),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
prefs.hiddenCalendarIds,
|
|
||||||
prefs.disabledCalendarIds,
|
|
||||||
) { instances, hidden, disabled ->
|
|
||||||
val excluded = hidden + disabled
|
|
||||||
if (excluded.isEmpty()) instances
|
|
||||||
else instances.filterNot { it.calendarId in excluded }
|
|
||||||
}
|
}
|
||||||
// hidden and disabled both derive from one DataStore, so toggling
|
|
||||||
// either makes both re-emit and combine briefly surfaces the same
|
|
||||||
// list twice — collapse the duplicate so views don't re-render for it.
|
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.flowOn(io)
|
.flowOn(io)
|
||||||
|
|
||||||
|
// Instances are filtered by the system's VISIBLE flag ∪ the switch-offs
|
||||||
|
// still waiting to be written to it ∪ the app-side hidden set from the
|
||||||
|
// filter sheet. [calendars] stays unfiltered so those screens can list and
|
||||||
|
// re-enable invisible calendars.
|
||||||
|
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
||||||
|
combine(
|
||||||
|
visibilityTicks().reQuery {
|
||||||
|
// All three reads in one pass, so a list of instances is never
|
||||||
|
// filtered against a visibility snapshot from another tick.
|
||||||
|
QueriedInstances(
|
||||||
|
instances = dataSource.instances(
|
||||||
|
beginMillis = range.start.toEpochMillis(),
|
||||||
|
endMillis = range.endInclusive.toEpochMillis(),
|
||||||
|
),
|
||||||
|
switchedOffCalendarIds = invisibleCalendarIds() +
|
||||||
|
prefs.pendingDisabledCalendarIds.first(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
prefs.hiddenCalendarIds,
|
||||||
|
) { queried, hidden ->
|
||||||
|
val excluded = hidden + queried.switchedOffCalendarIds
|
||||||
|
if (excluded.isEmpty()) queried.instances
|
||||||
|
else queried.instances.filterNot { it.calendarId in excluded }
|
||||||
|
}
|
||||||
|
// Any DataStore edit re-emits the hidden set even when unchanged;
|
||||||
|
// collapse those so views don't re-render for them.
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.flowOn(io)
|
||||||
|
|
||||||
|
/** One instances query plus the visibility it must be filtered against. */
|
||||||
|
private data class QueriedInstances(
|
||||||
|
val instances: List<EventInstance>,
|
||||||
|
val switchedOffCalendarIds: Set<Long>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Calendars switched off at system level — hidden, and never reminded about. */
|
||||||
|
private suspend fun invisibleCalendarIds(): Set<Long> = calendarsSnapshot()
|
||||||
|
.filterNot { it.isVisibleInSystem }
|
||||||
|
.mapTo(mutableSetOf()) { it.id }
|
||||||
|
|
||||||
|
private val calendarsLock = Mutex()
|
||||||
|
private var cachedGeneration = -1L
|
||||||
|
private var cachedPending: Set<Long>? = null
|
||||||
|
private var cachedCalendars: List<CalendarSource> = emptyList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar list for the current tick, queried once and shared, so every
|
||||||
|
* open view doesn't pay for its own `Calendars` query and all of them see
|
||||||
|
* one snapshot.
|
||||||
|
*
|
||||||
|
* An empty result is never cached — that is what a read without the calendar
|
||||||
|
* permission returns, and the grant itself doesn't notify the provider. The
|
||||||
|
* pending switch-off set keys the cache alongside the tick, because an id
|
||||||
|
* leaves it before the invalidating observer is dispatched.
|
||||||
|
*/
|
||||||
|
private suspend fun calendarsSnapshot(): List<CalendarSource> = calendarsLock.withLock {
|
||||||
|
val current = generation.get()
|
||||||
|
val pending = prefs.pendingDisabledCalendarIds.first()
|
||||||
|
if (current != cachedGeneration || pending != cachedPending || cachedCalendars.isEmpty()) {
|
||||||
|
cachedCalendars = dataSource.calendars()
|
||||||
|
cachedGeneration = current
|
||||||
|
cachedPending = pending
|
||||||
|
}
|
||||||
|
cachedCalendars
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
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 } }
|
||||||
}
|
}
|
||||||
@@ -118,6 +192,22 @@ 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 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>?) =
|
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
||||||
withContext(io) { dataSource.exportableEvents(calendarIds) }
|
withContext(io) { dataSource.exportableEvents(calendarIds) }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.calendarVisibilityPlan
|
||||||
|
import de.jeanlucmakiola.calendula.domain.hasSystemHiddenCalendars
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps [CalendarPrefs.pendingDisabledCalendarIds] and the system's
|
||||||
|
* `Calendars.VISIBLE` in step (#75): the fold-in of the retired app-local
|
||||||
|
* visibility model, and the standing drain for switch-offs made without
|
||||||
|
* `WRITE_CALENDAR`.
|
||||||
|
*
|
||||||
|
* Runs on every launch and on every calendar-permission grant; a no-op once the
|
||||||
|
* pending set is empty and the notice is settled. Only ever hides (see
|
||||||
|
* [calendarVisibilityPlan]); on an upgraded install the first run that sees a
|
||||||
|
* system-hidden calendar arms the one-time explanatory notice.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class CalendarVisibilityReconciler @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val dataSource: CalendarDataSource,
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun run() = withContext(io) {
|
||||||
|
// The DataStore reads sit inside the guard too: this runs in a bare
|
||||||
|
// application-scope coroutine, so an IOException from a damaged
|
||||||
|
// preferences file would take the process down on every launch.
|
||||||
|
try {
|
||||||
|
// Settled ahead of the permission gate, so an update installed
|
||||||
|
// before the first grant can't later look like an upgrade.
|
||||||
|
if (!isUpgradeInstall()) settleNoticeOnce(pending = false)
|
||||||
|
if (!hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
|
||||||
|
val pending = prefs.pendingDisabledCalendarIds.first()
|
||||||
|
val noticeSettled = prefs.visibilityNoticePending.first() != null
|
||||||
|
if (pending.isEmpty() && noticeSettled) return@withContext
|
||||||
|
val calendars = dataSource.calendars()
|
||||||
|
// Empty means "couldn't read" (null cursor), not "no calendars".
|
||||||
|
// Both decisions below are one-way, so leave them to the next run.
|
||||||
|
if (calendars.isEmpty()) return@withContext
|
||||||
|
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
|
||||||
|
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
val plan = calendarVisibilityPlan(calendars, pending)
|
||||||
|
prefs.removePendingDisabledCalendarIds(plan.settled)
|
||||||
|
// One calendar per write: the provider skips its reminder-alarm
|
||||||
|
// reschedule for anything but a single-id update (see
|
||||||
|
// [CalendarDataSource.setCalendarVisible]).
|
||||||
|
for (id in plan.hide) {
|
||||||
|
dataSource.setCalendarVisible(id, false)
|
||||||
|
prefs.removePendingDisabledCalendarIds(setOf(id))
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Calendar visibility reconcile failed; will retry", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle the one-time notice: [pending] arms it, false retires it unshown.
|
||||||
|
* Answered once and stored either way, so it can't resurface later.
|
||||||
|
*/
|
||||||
|
private suspend fun settleNoticeOnce(pending: Boolean) {
|
||||||
|
if (prefs.visibilityNoticePending.first() != null) return
|
||||||
|
prefs.setVisibilityNoticePending(pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this install has ever run an earlier version. The notice explains
|
||||||
|
* a behaviour change, so a first install has nothing to announce.
|
||||||
|
*/
|
||||||
|
private fun isUpgradeInstall(): Boolean = try {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
|
info.lastUpdateTime > info.firstInstallTime
|
||||||
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
|
Log.w(TAG, "Own package info unavailable; treating as a fresh install", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasPermission(permission: String): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/** Lets non-injectable entry points (the Application) reach the reconciler. */
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface Deps {
|
||||||
|
fun calendarVisibilityReconciler(): CalendarVisibilityReconciler
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "CalendarVisibility"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ 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
|
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
||||||
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
||||||
@@ -89,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,13 +37,38 @@ 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
|
||||||
@@ -104,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,
|
||||||
@@ -134,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)
|
||||||
@@ -149,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))
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,56 @@ 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 any
|
||||||
prefs[HIDDEN_IDS_KEY].orEmpty()
|
// unrelated write would otherwise re-emit an identical set.
|
||||||
.split(',')
|
val hiddenCalendarIds: Flow<Set<Long>> = store.data
|
||||||
.mapNotNull { it.trim().toLongOrNull() }
|
.map { prefs -> prefs[HIDDEN_IDS_KEY].parseIds() }
|
||||||
.toSet()
|
.distinctUntilChanged()
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setHiddenCalendarIds(ids: Set<Long>) {
|
suspend fun setHiddenCalendarIds(ids: Set<Long>) {
|
||||||
|
store.edit { prefs -> prefs.writeIds(HIDDEN_IDS_KEY, ids) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch-offs the provider does not know about yet, because writing
|
||||||
|
* `Calendars.VISIBLE` needs `WRITE_CALENDAR` (#75). Also inherits the
|
||||||
|
* retired app-local model's set, from the same key.
|
||||||
|
*
|
||||||
|
* Honoured as a display and reminder filter while non-empty, but not a
|
||||||
|
* second visibility model: `CalendarVisibilityReconciler` drains it entry by
|
||||||
|
* entry as soon as the app may write, and nothing adds to it while it may.
|
||||||
|
*/
|
||||||
|
val pendingDisabledCalendarIds: Flow<Set<Long>> = store.data
|
||||||
|
.map { prefs -> prefs[DISABLED_IDS_KEY].parseIds() }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
|
||||||
|
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) =
|
||||||
|
editPendingDisabled { it + ids }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop [ids] from the pending set, one at a time as the reconciler flushes
|
||||||
|
* them, so a run that fails part-way never re-applies what already landed.
|
||||||
|
*/
|
||||||
|
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) =
|
||||||
|
editPendingDisabled { it - ids.toSet() }
|
||||||
|
|
||||||
|
private suspend fun editPendingDisabled(transform: (Set<Long>) -> Set<Long>) {
|
||||||
store.edit { prefs ->
|
store.edit { prefs ->
|
||||||
if (ids.isEmpty()) {
|
prefs.writeIds(DISABLED_IDS_KEY, transform(prefs[DISABLED_IDS_KEY].parseIds()))
|
||||||
prefs.remove(HIDDEN_IDS_KEY)
|
|
||||||
} else {
|
|
||||||
prefs[HIDDEN_IDS_KEY] = ids.sorted().joinToString(",")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 +96,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,36 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.prefs
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far reminder delivery has got — the one number replacing the provider's
|
||||||
|
* `CalendarAlerts.STATE` (#75). A scan posts the reminders falling after this
|
||||||
|
* watermark and up to now, then moves it to now, so a scan running twice cannot
|
||||||
|
* post twice while a late one still catches up.
|
||||||
|
*
|
||||||
|
* Unset means "never scanned", not zero: the first scan after an install would
|
||||||
|
* otherwise treat every reminder since the epoch as overdue.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReminderStatePrefs @Inject constructor(
|
||||||
|
private val store: DataStore<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** The watermark, or `null` before the first scan has ever run. */
|
||||||
|
suspend fun lastScanMillis(): Long? = store.data.map { it[LAST_SCAN_KEY] }.first()
|
||||||
|
|
||||||
|
suspend fun setLastScanMillis(millis: Long) {
|
||||||
|
store.edit { prefs -> prefs[LAST_SCAN_KEY] = millis }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val LAST_SCAN_KEY = longPreferencesKey("reminder_last_scan_millis")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,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
|
||||||
@@ -81,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
|
||||||
@@ -114,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
|
||||||
@@ -218,6 +258,33 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled }
|
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
|
||||||
@@ -240,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.
|
||||||
@@ -252,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
|
||||||
@@ -325,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
|
||||||
@@ -621,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(',')
|
||||||
@@ -665,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")
|
||||||
@@ -673,11 +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 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")
|
||||||
@@ -704,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")
|
||||||
|
|||||||
@@ -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,16 @@ 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 duplicates no information but is what keeps two reminders'
|
||||||
|
* `PendingIntent`s apart — `filterEquals` never compares extras.
|
||||||
|
*/
|
||||||
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
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 +112,25 @@ 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. The shift keeps the action slot intact;
|
||||||
|
* the top bits it drops are separated by [intent]'s per-reminder URI.
|
||||||
*/
|
*/
|
||||||
fun requestCode(alert: ReminderAlert, action: String): Int {
|
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,64 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True on API < 31 (no restriction), and on 31+ when the exact-alarm capability
|
||||||
|
* is held — auto-granted via `USE_EXACT_ALARM` on API 33+ (Calendula is a
|
||||||
|
* calendar app), user-revocable on 31–32.
|
||||||
|
*/
|
||||||
|
internal fun AlarmManager.canScheduleExactCompat(): Boolean =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the app's own wake-up for the next reminder (#75). Exactly one alarm
|
||||||
|
* exists at a time, for the earliest reminder ahead; every firing re-scans and
|
||||||
|
* re-arms. Exact, with an inexact allow-while-idle fallback where the OS
|
||||||
|
* withholds the capability (API 31–32 with the permission revoked).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReminderAlarmScheduler @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun scheduleScan(triggerAtMillis: Long) {
|
||||||
|
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||||
|
val pendingIntent = scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT)
|
||||||
|
if (alarmManager.canScheduleExactCompat()) {
|
||||||
|
alarmManager.setExactAndAllowWhileIdle(
|
||||||
|
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
alarmManager.setAndAllowWhileIdle(
|
||||||
|
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */
|
||||||
|
fun cancelScan() {
|
||||||
|
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||||
|
alarmManager.cancel(scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scanPendingIntent(flags: Int): PendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
SCAN_REQUEST_CODE,
|
||||||
|
Intent(context, ReminderScheduleReceiver::class.java)
|
||||||
|
.setAction(ReminderScheduleReceiver.ACTION_SCAN),
|
||||||
|
flags or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
// Fixed: there is only ever one scan alarm, and re-arming must replace it.
|
||||||
|
const val SCAN_REQUEST_CODE = 0x5CA1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One reminder as the notification layer needs it: what to show, and the stable
|
||||||
|
* [key] identifying it across a reboot, a re-scan and a reinstall. Derived from
|
||||||
|
* the reminder itself (see [PlannedReminder.key]) — in-house delivery has no
|
||||||
|
* `CalendarAlerts` row to take an id from (#75).
|
||||||
|
*/
|
||||||
|
data class ReminderAlert(
|
||||||
|
val key: Long,
|
||||||
|
val eventId: Long,
|
||||||
|
val calendarId: Long,
|
||||||
|
val beginMillis: Long,
|
||||||
|
val endMillis: Long,
|
||||||
|
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||||
|
val title: String,
|
||||||
|
val location: String?,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun PlannedReminder.toAlert(): ReminderAlert = ReminderAlert(
|
||||||
|
key = key,
|
||||||
|
eventId = instance.eventId,
|
||||||
|
calendarId = instance.calendarId,
|
||||||
|
beginMillis = instance.beginMillis,
|
||||||
|
endMillis = instance.endMillis,
|
||||||
|
title = instance.title,
|
||||||
|
location = instance.location,
|
||||||
|
isAllDay = instance.isAllDay,
|
||||||
|
)
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
|
||||||
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
|
||||||
import android.provider.CalendarContract
|
|
||||||
import android.util.Log
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One due row of the provider's `CalendarAlerts` table (a join with Events).
|
|
||||||
* Stays in the data layer: alerts feed the notification path only and never
|
|
||||||
* reach a screen, so there is no domain model for them.
|
|
||||||
*/
|
|
||||||
data class ReminderAlert(
|
|
||||||
val alertId: Long,
|
|
||||||
val eventId: Long,
|
|
||||||
val calendarId: Long,
|
|
||||||
val beginMillis: Long,
|
|
||||||
val endMillis: Long,
|
|
||||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
|
||||||
val title: String,
|
|
||||||
val location: String?,
|
|
||||||
val isAllDay: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seam over the `CalendarAlerts` table so the receiver logic can be exercised
|
|
||||||
* without a ContentResolver. The provider creates these rows itself — only
|
|
||||||
* for `METHOD_ALERT` reminders (verified in AOSP `CalendarAlarmManager`), so
|
|
||||||
* email reminders never show up here.
|
|
||||||
*/
|
|
||||||
interface ReminderAlertStore {
|
|
||||||
|
|
||||||
/** Alerts that are due (`ALARM_TIME` has passed) and still unhandled. */
|
|
||||||
fun dueAlerts(nowMillis: Long): List<ReminderAlert>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark the given alerts handled (`STATE_FIRED`) so a later broadcast does
|
|
||||||
* not surface them again. Best effort: this write needs `WRITE_CALENDAR`,
|
|
||||||
* which the user may have declined — then re-broadcasts silently replace
|
|
||||||
* the already-posted notifications instead (same tag, alert-once).
|
|
||||||
*/
|
|
||||||
fun markFired(alertIds: List<Long>, nowMillis: Long)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class AndroidReminderAlertStore @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
) : ReminderAlertStore {
|
|
||||||
|
|
||||||
override fun dueAlerts(nowMillis: Long): List<ReminderAlert> = context.contentResolver.query(
|
|
||||||
CalendarContract.CalendarAlerts.CONTENT_URI,
|
|
||||||
PROJECTION,
|
|
||||||
CalendarContract.CalendarAlerts.STATE + " = ? AND " +
|
|
||||||
CalendarContract.CalendarAlerts.ALARM_TIME + " <= ?",
|
|
||||||
arrayOf(
|
|
||||||
CalendarContract.CalendarAlerts.STATE_SCHEDULED.toString(),
|
|
||||||
nowMillis.toString(),
|
|
||||||
),
|
|
||||||
CalendarContract.CalendarAlerts.BEGIN + " ASC",
|
|
||||||
)?.use { c ->
|
|
||||||
buildList {
|
|
||||||
while (c.moveToNext()) {
|
|
||||||
add(
|
|
||||||
ReminderAlert(
|
|
||||||
alertId = c.getLong(0),
|
|
||||||
eventId = c.getLong(1),
|
|
||||||
calendarId = c.getLong(2),
|
|
||||||
beginMillis = c.getLong(3),
|
|
||||||
endMillis = c.getLong(4),
|
|
||||||
title = c.getString(5).orEmpty(),
|
|
||||||
location = c.getString(6)?.takeIf { it.isNotBlank() },
|
|
||||||
isAllDay = c.getInt(7) == 1,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} ?: emptyList()
|
|
||||||
|
|
||||||
override fun markFired(alertIds: List<Long>, nowMillis: Long) {
|
|
||||||
if (alertIds.isEmpty()) return
|
|
||||||
val values = ContentValues().apply {
|
|
||||||
put(CalendarContract.CalendarAlerts.STATE, CalendarContract.CalendarAlerts.STATE_FIRED)
|
|
||||||
put(CalendarContract.CalendarAlerts.RECEIVED_TIME, nowMillis)
|
|
||||||
put(CalendarContract.CalendarAlerts.NOTIFY_TIME, nowMillis)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
context.contentResolver.update(
|
|
||||||
CalendarContract.CalendarAlerts.CONTENT_URI,
|
|
||||||
values,
|
|
||||||
CalendarContract.CalendarAlerts._ID +
|
|
||||||
" IN (" + alertIds.joinToString(",") + ")",
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "Cannot mark alerts fired without WRITE_CALENDAR", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TAG = "ReminderAlertStore"
|
|
||||||
val PROJECTION = arrayOf(
|
|
||||||
CalendarContract.CalendarAlerts._ID,
|
|
||||||
CalendarContract.CalendarAlerts.EVENT_ID,
|
|
||||||
CalendarContract.CalendarAlerts.CALENDAR_ID,
|
|
||||||
CalendarContract.CalendarAlerts.BEGIN,
|
|
||||||
CalendarContract.CalendarAlerts.END,
|
|
||||||
CalendarContract.CalendarAlerts.TITLE,
|
|
||||||
CalendarContract.CalendarAlerts.EVENT_LOCATION,
|
|
||||||
CalendarContract.CalendarAlerts.ALL_DAY,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The read side of in-house reminder delivery: occurrences and their reminder
|
||||||
|
* offsets, read from `Instances` and `Reminders` rather than `CalendarAlerts`,
|
||||||
|
* which cannot be assumed to be written (#75).
|
||||||
|
*
|
||||||
|
* An interface so [ReminderScanner] can be exercised on the JVM.
|
||||||
|
*/
|
||||||
|
interface ReminderInstanceSource {
|
||||||
|
|
||||||
|
/** Occurrences overlapping `[fromMillis, toMillis]`, of switched-on calendars. */
|
||||||
|
fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance>
|
||||||
|
|
||||||
|
/** `METHOD_ALERT` reminder offsets per event id, for the given events. */
|
||||||
|
fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The largest `METHOD_ALERT` offset anywhere in the table, so the query
|
||||||
|
* window can be stretched to cover it and a long-lead reminder is planned
|
||||||
|
* before it comes due rather than firing late.
|
||||||
|
*/
|
||||||
|
fun longestReminderMinutes(): Int
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ProviderReminderInstanceSource @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : ReminderInstanceSource {
|
||||||
|
|
||||||
|
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
|
||||||
|
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||||
|
ContentUris.appendId(this, fromMillis)
|
||||||
|
ContentUris.appendId(this, toMillis)
|
||||||
|
}.build()
|
||||||
|
// `visible` is the flag the app's one visibility model writes (#75).
|
||||||
|
// The status clause mirrors CalendarDataSource.instances: NULL means
|
||||||
|
// "normal", so a bare `!= CANCELED` would drop every ordinary event.
|
||||||
|
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
|
||||||
|
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||||
|
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})"
|
||||||
|
return context.contentResolver.query(
|
||||||
|
uri, OCCURRENCE_PROJECTION, selection, null, null,
|
||||||
|
)?.use { c ->
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
add(
|
||||||
|
ReminderEventInstance(
|
||||||
|
eventId = c.getLong(0),
|
||||||
|
calendarId = c.getLong(1),
|
||||||
|
beginMillis = c.getLong(2),
|
||||||
|
endMillis = if (c.isNull(3)) 0L else c.getLong(3),
|
||||||
|
title = c.getString(4).orEmpty(),
|
||||||
|
location = c.getString(5)?.takeIf { it.isNotBlank() },
|
||||||
|
isAllDay = c.getInt(6) == 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>> {
|
||||||
|
if (eventIds.isEmpty()) return emptyMap()
|
||||||
|
val out = mutableMapOf<Long, MutableList<Int>>()
|
||||||
|
// Batched: the ids go into the selection literally, and an unbounded
|
||||||
|
// `IN (...)` would grow the SQL past what SQLite takes.
|
||||||
|
eventIds.distinct().chunked(EVENT_ID_BATCH).forEach { batch ->
|
||||||
|
context.contentResolver.query(
|
||||||
|
CalendarContract.Reminders.CONTENT_URI,
|
||||||
|
REMINDER_PROJECTION,
|
||||||
|
"${CalendarContract.Reminders.METHOD} = " +
|
||||||
|
"${CalendarContract.Reminders.METHOD_ALERT} AND " +
|
||||||
|
"${CalendarContract.Reminders.EVENT_ID} IN (${batch.joinToString(",")})",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
out.getOrPut(c.getLong(0)) { mutableListOf() } += c.getInt(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun longestReminderMinutes(): Int = context.contentResolver.query(
|
||||||
|
CalendarContract.Reminders.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Reminders.MINUTES),
|
||||||
|
"${CalendarContract.Reminders.METHOD} = ${CalendarContract.Reminders.METHOD_ALERT}",
|
||||||
|
null,
|
||||||
|
// One row is enough: the provider passes the sort order to SQLite.
|
||||||
|
"${CalendarContract.Reminders.MINUTES} DESC",
|
||||||
|
)?.use { c -> if (c.moveToFirst()) c.getInt(0) else 0 } ?: 0
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val EVENT_ID_BATCH = 50
|
||||||
|
|
||||||
|
val OCCURRENCE_PROJECTION = arrayOf(
|
||||||
|
CalendarContract.Instances.EVENT_ID,
|
||||||
|
CalendarContract.Instances.CALENDAR_ID,
|
||||||
|
CalendarContract.Instances.BEGIN,
|
||||||
|
CalendarContract.Instances.END,
|
||||||
|
CalendarContract.Instances.TITLE,
|
||||||
|
CalendarContract.Instances.EVENT_LOCATION,
|
||||||
|
CalendarContract.Instances.ALL_DAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
val REMINDER_PROJECTION = arrayOf(
|
||||||
|
CalendarContract.Reminders.EVENT_ID,
|
||||||
|
CalendarContract.Reminders.MINUTES,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backstop under the alarm: a daily scan that runs whether or not the alarm
|
||||||
|
* survived. Finds nothing to do in the steady state; it exists for the device
|
||||||
|
* that quietly drops the alarm without a reboot to announce it (#75).
|
||||||
|
*/
|
||||||
|
object ReminderMaintenanceScheduler {
|
||||||
|
|
||||||
|
private const val WORK_NAME = "reminder-scan-maintenance"
|
||||||
|
|
||||||
|
/** Enqueue the daily backstop; idempotent, so every launch may call it. */
|
||||||
|
fun apply(context: Context) {
|
||||||
|
val request = PeriodicWorkRequestBuilder<ReminderMaintenanceWorker>(1, TimeUnit.DAYS)
|
||||||
|
// The launch scan covers now; let the first periodic run wait.
|
||||||
|
.setInitialDelay(1, TimeUnit.DAYS)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReminderMaintenanceWorker(
|
||||||
|
appContext: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface Deps {
|
||||||
|
fun reminderScanner(): ReminderScanner
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result = try {
|
||||||
|
EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||||
|
.reminderScanner()
|
||||||
|
.scan()
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// The scan swallows its own failures, so anything reaching here is the
|
||||||
|
// entry point itself — a retry will not mend it.
|
||||||
|
Log.w(TAG, "Reminder maintenance scan failed", e)
|
||||||
|
Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ReminderMaintenance"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ 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
|
||||||
@@ -28,17 +29,20 @@ 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. */
|
||||||
@@ -49,12 +53,23 @@ 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", covering the
|
||||||
// the provider broadcast (EventReminderReceiver) and a snoozed re-show
|
* two paths that reach [post] around the scan's own filter: a snooze armed
|
||||||
// (ReminderActionReceiver), so a calendar disabled after a snooze no
|
* before the switch-off, and a read-only install whose switch lives in
|
||||||
// longer notifies — without either receiver duplicating the check.
|
* [CalendarPrefs].
|
||||||
if (alert.isForDisabledCalendar(calendarPrefs.disabledCalendarIds.first())) return
|
*/
|
||||||
|
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()
|
||||||
@@ -101,16 +116,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 — a retry hits 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 =
|
||||||
@@ -123,7 +140,10 @@ 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.
|
||||||
|
/* 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,158 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
|
||||||
|
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
|
||||||
|
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
|
||||||
|
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.time.ZoneId
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One pass of in-house reminder delivery: read what is planned, post what has
|
||||||
|
* come due, and arm the next wake-up. Every trigger runs the same [scan], and
|
||||||
|
* re-running is always safe — the watermark in [ReminderStatePrefs] decides what
|
||||||
|
* is owed, not the trigger.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReminderScanner @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val source: ReminderInstanceSource,
|
||||||
|
private val calendarDataSource: CalendarDataSource,
|
||||||
|
private val notifier: ReminderNotifier,
|
||||||
|
private val alarms: ReminderAlarmScheduler,
|
||||||
|
private val state: ReminderStatePrefs,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
|
@IoDispatcher private val io: kotlinx.coroutines.CoroutineDispatcher,
|
||||||
|
) {
|
||||||
|
|
||||||
|
// Triggers overlap freely (an alarm during a burst of edits); serialize so
|
||||||
|
// two passes can't both read the same watermark and post the same reminder.
|
||||||
|
private val scanLock = Mutex()
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + io)
|
||||||
|
private val providerChanges = MutableSharedFlow<Unit>(
|
||||||
|
replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
private var watching = false
|
||||||
|
|
||||||
|
suspend fun scan() = withContext(io) {
|
||||||
|
scanLock.withLock {
|
||||||
|
try {
|
||||||
|
runScan()
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// Permission revoked mid-flight; the next grant re-scans.
|
||||||
|
Log.w(TAG, "Reminder scan lacks the calendar permission", e)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Reminder scan failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runScan() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (!hasReadCalendar()) return
|
||||||
|
|
||||||
|
if (!settingsPrefs.remindersEnabled.first()) {
|
||||||
|
// Reminders off: drop the wake-up, but keep the watermark moving
|
||||||
|
// so switching them back on doesn't replay the backlog.
|
||||||
|
alarms.cancelScan()
|
||||||
|
state.setLastScanMillis(now)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
|
||||||
|
// Reach into the past too: an all-day "at time of event" encodes to a
|
||||||
|
// negative offset, and a catch-up pass needs the occurrences it missed.
|
||||||
|
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
|
||||||
|
val planned = planReminders(
|
||||||
|
instances = occurrences,
|
||||||
|
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId }),
|
||||||
|
zone = ZoneId.systemDefault(),
|
||||||
|
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val schedule = scheduleReminders(
|
||||||
|
planned = planned,
|
||||||
|
lastFiredMillis = reminderWatermark(state.lastScanMillis(), now),
|
||||||
|
nowMillis = now,
|
||||||
|
horizonMillis = now + MAX_ALARM_INTERVAL_MILLIS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (notifier.canPost()) {
|
||||||
|
schedule.due.forEach { notifier.post(it.toAlert()) }
|
||||||
|
}
|
||||||
|
// Advance even when nothing could be posted, so muting notifications
|
||||||
|
// doesn't build a backlog.
|
||||||
|
state.setLastScanMillis(now)
|
||||||
|
alarms.scheduleScan(schedule.nextAlarmMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasReadCalendar(): Boolean = ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.READ_CALENDAR,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-scan when the provider changes, so a saved or deleted event re-arms the
|
||||||
|
* alarm at once. Debounced, since a single save lands as several
|
||||||
|
* notifications. Process-lifetime only; other triggers cover the rest.
|
||||||
|
*/
|
||||||
|
fun startWatchingProvider() {
|
||||||
|
if (watching) return
|
||||||
|
watching = true
|
||||||
|
providerChanges
|
||||||
|
.debounce(PROVIDER_CHANGE_DEBOUNCE_MILLIS)
|
||||||
|
.onEach { scan() }
|
||||||
|
.launchIn(scope)
|
||||||
|
calendarDataSource.registerChangeListener { providerChanges.tryEmit(Unit) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget scan for callers that are not in a coroutine already. */
|
||||||
|
fun scanInBackground() {
|
||||||
|
scope.launch(start = CoroutineStart.DEFAULT) { scan() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ReminderScanner"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far ahead occurrences are read. Stretched further by the longest
|
||||||
|
* reminder offset in the table, so this is only the floor.
|
||||||
|
*/
|
||||||
|
const val LOOKAHEAD_MILLIS = 7L * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** How far back to look for occurrences that may still owe a reminder. */
|
||||||
|
const val PAST_WINDOW_MILLIS = 24L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never wait longer than a day for the next pass: it rolls the lookahead
|
||||||
|
* window forward and re-arms an alarm the system may have dropped.
|
||||||
|
*/
|
||||||
|
const val MAX_ALARM_INTERVAL_MILLIS = 24L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
const val PROVIDER_CHANGE_DEBOUNCE_MILLIS = 2_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every out-of-process reason to re-run a reminder scan: our own [ACTION_SCAN]
|
||||||
|
* alarm, boot and package-replaced (both wipe pending alarms), and time or
|
||||||
|
* timezone changes (both move reminders relative to the armed alarm). All do the
|
||||||
|
* same thing, since [ReminderScanner.scan] is idempotent.
|
||||||
|
*
|
||||||
|
* Exported for the system broadcasts; an early scan triggered by another app is
|
||||||
|
* harmless.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class ReminderScheduleReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@Inject lateinit var scanner: ReminderScanner
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
// Checked despite every action doing the same thing: the receiver is
|
||||||
|
// exported and the broadcasts it takes are protected, so any other
|
||||||
|
// action did not come from where it claims to.
|
||||||
|
if (intent.action !in HANDLED_ACTIONS) return
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
scanner.scan()
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_SCAN = "de.jeanlucmakiola.calendula.reminders.SCAN"
|
||||||
|
|
||||||
|
private val HANDLED_ACTIONS = setOf(
|
||||||
|
ACTION_SCAN,
|
||||||
|
Intent.ACTION_BOOT_COMPLETED,
|
||||||
|
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||||
|
Intent.ACTION_TIME_CHANGED,
|
||||||
|
Intent.ACTION_TIMEZONE_CHANGED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,14 +10,13 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
|
* Schedules a one-off exact alarm that re-shows a snoozed reminder. Separate
|
||||||
|
* from [ReminderAlarmScheduler]'s single moving scan alarm: a snooze is pinned
|
||||||
|
* to one reminder and has to outlive the watermark moving past it, so it carries
|
||||||
|
* the reminder in its own intent.
|
||||||
*
|
*
|
||||||
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
|
* Falls back to an inexact allow-while-idle alarm where the OS withholds the
|
||||||
* broadcast (the Etar model), but a snoozed reminder has no provider backing —
|
* exact-alarm capability (API 31–32 with the permission revoked).
|
||||||
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
|
|
||||||
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
|
|
||||||
* back to an inexact allow-while-idle alarm only if the OS withholds the
|
|
||||||
* exact-alarm capability (API 31–32 where the user revoked it).
|
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ReminderSnoozeScheduler @Inject constructor(
|
class ReminderSnoozeScheduler @Inject constructor(
|
||||||
@@ -37,8 +36,8 @@ class ReminderSnoozeScheduler @Inject constructor(
|
|||||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Exact alarms revoked (API 31–32): an inexact wake is the honest
|
// Exact alarms revoked (API 31–32); an inexact wake is the best
|
||||||
// best we can do without nagging for SCHEDULE_EXACT_ALARM.
|
// available without nagging for SCHEDULE_EXACT_ALARM.
|
||||||
alarmManager.setAndAllowWhileIdle(
|
alarmManager.setAndAllowWhileIdle(
|
||||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
|
||||||
|
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.datastore.preferences.core.MutablePreferences
|
|
||||||
import androidx.datastore.preferences.core.Preferences
|
|
||||||
import androidx.datastore.preferences.core.edit
|
|
||||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
|
||||||
import java.util.Base64
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Still relevant while the event has not ended: a reminder for an event that is
|
|
||||||
* already over is pointless to re-surface. Falls back to the begin time when the
|
|
||||||
* end is unknown (0L). Used both to decide what to re-post and to purge the stash.
|
|
||||||
*/
|
|
||||||
internal fun ReminderAlert.isRelevantAt(nowMillis: Long): Boolean =
|
|
||||||
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Local stash of reminder alerts that fired while their calendar was disabled
|
|
||||||
* in-app. [EventReminderReceiver] marks every due alert `STATE_FIRED` regardless
|
|
||||||
* (so the provider stops re-broadcasting the suppressed ones), which would
|
|
||||||
* otherwise lose those reminders forever — there is no re-scan. Stashing lets
|
|
||||||
* [de.jeanlucmakiola.calendula.ui.calendars.CalendarsViewModel] re-post them if
|
|
||||||
* the user re-enables the calendar before the event is over.
|
|
||||||
*
|
|
||||||
* Persisted in the shared preferences DataStore as a set of self-describing
|
|
||||||
* strings (one per alert); the stash never reaches a screen, so there is no
|
|
||||||
* domain model. Entries whose event has already ended are dropped on the next
|
|
||||||
* stash/recover/purge, so the stash only ever holds a handful of pending alerts.
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class SuppressedReminderStore @Inject constructor(
|
|
||||||
private val store: DataStore<Preferences>,
|
|
||||||
) {
|
|
||||||
|
|
||||||
/** Add [alerts] to the stash, replacing any existing entry with the same id. */
|
|
||||||
suspend fun stash(alerts: List<ReminderAlert>, nowMillis: Long) {
|
|
||||||
if (alerts.isEmpty()) return
|
|
||||||
store.edit { prefs ->
|
|
||||||
val byId = decodeAll(prefs).associateByTo(mutableMapOf()) { it.alertId }
|
|
||||||
alerts.forEach { byId[it.alertId] = it }
|
|
||||||
prefs.putStash(byId.values.filter { it.isRelevantAt(nowMillis) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove and return the still-relevant stashed alerts belonging to any of
|
|
||||||
* [calendarIds]; drops expired entries for every calendar in passing.
|
|
||||||
*/
|
|
||||||
suspend fun recoverFor(calendarIds: Set<Long>, nowMillis: Long): List<ReminderAlert> {
|
|
||||||
val recovered = mutableListOf<ReminderAlert>()
|
|
||||||
store.edit { prefs ->
|
|
||||||
val kept = decodeAll(prefs).filter { alert ->
|
|
||||||
when {
|
|
||||||
!alert.isRelevantAt(nowMillis) -> false // expired: drop
|
|
||||||
alert.calendarId in calendarIds -> { recovered += alert; false }
|
|
||||||
else -> true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prefs.putStash(kept)
|
|
||||||
}
|
|
||||||
return recovered
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop entries whose event has already ended — cheap opportunistic cleanup. */
|
|
||||||
suspend fun purgeExpired(nowMillis: Long) {
|
|
||||||
store.edit { prefs ->
|
|
||||||
prefs.putStash(decodeAll(prefs).filter { it.isRelevantAt(nowMillis) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun decodeAll(prefs: Preferences): List<ReminderAlert> =
|
|
||||||
prefs[KEY].orEmpty().mapNotNull { decodeStashEntry(it) }
|
|
||||||
|
|
||||||
private fun MutablePreferences.putStash(alerts: List<ReminderAlert>) {
|
|
||||||
val encoded = alerts.map { encodeStashEntry(it) }.toSet()
|
|
||||||
if (encoded.isEmpty()) remove(KEY) else set(KEY, encoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val KEY = stringSetPreferencesKey("suppressed_reminders")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// One stash entry as a delimited string. The '|' separator is safe because every
|
|
||||||
// free-text field is Base64-encoded first (that alphabet never contains '|'), and
|
|
||||||
// a null location is stored as a distinct sentinel that Base64 also never yields.
|
|
||||||
private const val FIELD_SEP = "|"
|
|
||||||
private const val NULL_LOCATION = "-"
|
|
||||||
|
|
||||||
internal fun encodeStashEntry(alert: ReminderAlert): String = listOf(
|
|
||||||
alert.alertId.toString(),
|
|
||||||
alert.eventId.toString(),
|
|
||||||
alert.calendarId.toString(),
|
|
||||||
alert.beginMillis.toString(),
|
|
||||||
alert.endMillis.toString(),
|
|
||||||
if (alert.isAllDay) "1" else "0",
|
|
||||||
alert.title.toBase64(),
|
|
||||||
alert.location?.toBase64() ?: NULL_LOCATION,
|
|
||||||
).joinToString(FIELD_SEP)
|
|
||||||
|
|
||||||
/** Reverse of [encodeStashEntry]; returns null for a malformed entry (dropped). */
|
|
||||||
internal fun decodeStashEntry(raw: String): ReminderAlert? {
|
|
||||||
val parts = raw.split(FIELD_SEP)
|
|
||||||
if (parts.size != 8) return null
|
|
||||||
return try {
|
|
||||||
ReminderAlert(
|
|
||||||
alertId = parts[0].toLong(),
|
|
||||||
eventId = parts[1].toLong(),
|
|
||||||
calendarId = parts[2].toLong(),
|
|
||||||
beginMillis = parts[3].toLong(),
|
|
||||||
endMillis = parts[4].toLong(),
|
|
||||||
title = parts[6].fromBase64(),
|
|
||||||
location = parts[7].takeIf { it != NULL_LOCATION }?.fromBase64(),
|
|
||||||
isAllDay = parts[5] == "1",
|
|
||||||
)
|
|
||||||
} catch (e: NumberFormatException) {
|
|
||||||
null
|
|
||||||
} catch (e: IllegalArgumentException) { // bad Base64
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.toBase64(): String =
|
|
||||||
Base64.getEncoder().encodeToString(toByteArray(Charsets.UTF_8))
|
|
||||||
|
|
||||||
private fun String.fromBase64(): String =
|
|
||||||
String(Base64.getDecoder().decode(this), Charsets.UTF_8)
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ways a calendar can behave unlike a plain, writable one — each a reason it
|
||||||
|
* is missing from the event and import pickers (#76).
|
||||||
|
*/
|
||||||
|
enum class CalendarStateLabel {
|
||||||
|
/**
|
||||||
|
* A special-dates mirror the app fills from contacts. Writable and visible,
|
||||||
|
* yet no event target: anything authored here is deleted by the next sync.
|
||||||
|
*/
|
||||||
|
MANAGED,
|
||||||
|
|
||||||
|
/** Contents can't be modified: a WebCal subscription, a read-only share. */
|
||||||
|
READ_ONLY,
|
||||||
|
|
||||||
|
/** The account holds the events, but this device isn't syncing them down. */
|
||||||
|
NOT_SYNCED,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the account keeps this calendar's events off the device
|
||||||
|
* (`Calendars.SYNC_EVENTS = 0`) — empty by construction. Device-local calendars
|
||||||
|
* are excluded: nothing syncs them by definition, and one from another app can
|
||||||
|
* hold real events at `sync_events = 0`.
|
||||||
|
*/
|
||||||
|
val CalendarSource.isNotSynced: Boolean
|
||||||
|
get() = !syncsEvents && !isLocal
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a visibility switch on this calendar can change anything the user
|
||||||
|
* would see — it can't for a non-syncing one, with no events on the device.
|
||||||
|
*/
|
||||||
|
val CalendarSource.hasVisibilitySwitch: Boolean
|
||||||
|
get() = !isNotSynced
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this calendar can be offered as a target for a new or imported event.
|
||||||
|
* The one predicate behind both pickers, so the states [CalendarStateLabel]
|
||||||
|
* names on a manager row are exactly the states that keep a calendar out of them
|
||||||
|
* (#76). An event already living in an excluded calendar keeps it; the editor
|
||||||
|
* adds that calendar back to its picker.
|
||||||
|
*/
|
||||||
|
val CalendarSource.isEventTarget: Boolean
|
||||||
|
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
|
||||||
|
|
||||||
|
/** Every state worth naming on this calendar's row, in reading order. */
|
||||||
|
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
||||||
|
if (isManaged) add(CalendarStateLabel.MANAGED)
|
||||||
|
if (!canModifyContents) add(CalendarStateLabel.READ_ONLY)
|
||||||
|
if (isNotSynced) add(CalendarStateLabel.NOT_SYNCED)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendar-manager order within one group: the ones you can actually act on
|
||||||
|
* first, the non-syncing ones after them. Stable otherwise, so the provider's
|
||||||
|
* display-name ordering survives.
|
||||||
|
*/
|
||||||
|
fun List<CalendarSource>.orderedForManager(): List<CalendarSource> =
|
||||||
|
sortedBy { it.isNotSynced }
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `Calendars.VISIBLE` writes that flush the app's pending "switched off"
|
||||||
|
* set into the provider, plus the ids that need no write at all.
|
||||||
|
*/
|
||||||
|
data class CalendarVisibilityPlan(
|
||||||
|
val hide: Set<Long> = emptySet(),
|
||||||
|
val settled: Set<Long> = emptySet(),
|
||||||
|
) {
|
||||||
|
val isEmpty: Boolean get() = hide.isEmpty() && settled.isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile [pendingDisabledIds] — switch-offs the app could not write, plus
|
||||||
|
* what the retired app-local visibility model left behind (#75) — against the
|
||||||
|
* calendars actually on the device.
|
||||||
|
*
|
||||||
|
* Only ever *hides*: switching a system-hidden calendar back on would un-hide it
|
||||||
|
* in every other calendar app too. Calendula follows the flag and explains
|
||||||
|
* itself once (see [hasSystemHiddenCalendars]).
|
||||||
|
*
|
||||||
|
* [CalendarVisibilityPlan.settled] carries the ids needing no write — already
|
||||||
|
* hidden, or gone from the device.
|
||||||
|
*/
|
||||||
|
fun calendarVisibilityPlan(
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
pendingDisabledIds: Set<Long>,
|
||||||
|
): CalendarVisibilityPlan {
|
||||||
|
val byId = calendars.associateBy { it.id }
|
||||||
|
val hide = mutableSetOf<Long>()
|
||||||
|
val settled = mutableSetOf<Long>()
|
||||||
|
for (id in pendingDisabledIds) {
|
||||||
|
val calendar = byId[id]
|
||||||
|
// Gone from the device, or already invisible — nothing to write.
|
||||||
|
if (calendar != null && calendar.isVisibleInSystem) hide += id else settled += id
|
||||||
|
}
|
||||||
|
return CalendarVisibilityPlan(hide = hide, settled = settled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether any calendar is switched off at system level without Calendula having
|
||||||
|
* asked for it — the condition the one-time notice explains.
|
||||||
|
*/
|
||||||
|
fun hasSystemHiddenCalendars(
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
pendingDisabledIds: Set<Long>,
|
||||||
|
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,11 @@ data class CalendarSource(
|
|||||||
val accountName: String,
|
val accountName: String,
|
||||||
val accountType: String,
|
val accountType: String,
|
||||||
val color: Int,
|
val color: Int,
|
||||||
|
/**
|
||||||
|
* The system's `Calendars.VISIBLE` flag — the single visibility model,
|
||||||
|
* deciding both what Calendula shows and whether this calendar plans
|
||||||
|
* reminders (#75). The drawer's filter sheet is a separate in-app declutter.
|
||||||
|
*/
|
||||||
val isVisibleInSystem: Boolean,
|
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 +43,13 @@ 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]. Says
|
||||||
|
* nothing about device-local calendars, which can hold events with it off.
|
||||||
|
* Read for the "not synced" row label (#76).
|
||||||
|
*/
|
||||||
|
val syncsEvents: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class EventInstance(
|
data class EventInstance(
|
||||||
@@ -56,6 +72,31 @@ 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: the device [zone] for timed
|
||||||
|
* events, UTC for all-day ones, whose midnights would otherwise shift day
|
||||||
|
* boundaries (#65, #82). Every surface naming an all-day date goes through here.
|
||||||
|
*/
|
||||||
|
fun EventInstance.dateZone(zone: TimeZone): TimeZone =
|
||||||
|
if (isAllDay) TimeZone.UTC else zone
|
||||||
|
|
||||||
|
/** The first calendar day this event occupies. */
|
||||||
|
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
|
||||||
|
start.toLocalDateTime(dateZone(zone)).date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last calendar day this event occupies. An event ending exactly at midnight
|
||||||
|
* does not reach into that day, so resolve just before [EventInstance.end].
|
||||||
|
*/
|
||||||
|
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
||||||
|
val lastInstant = if (end > start) end - 1.milliseconds else start
|
||||||
|
return lastInstant.toLocalDateTime(dateZone(zone)).date
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this event occupies more than one calendar day in [zone]. */
|
||||||
|
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
||||||
|
spanFirstDay(zone) != spanLastDay(zone)
|
||||||
|
|
||||||
data class EventDetail(
|
data class EventDetail(
|
||||||
val instance: EventInstance,
|
val instance: EventInstance,
|
||||||
val description: String?,
|
val description: String?,
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.isoDayNumber
|
||||||
|
import kotlinx.datetime.minus
|
||||||
|
import kotlinx.datetime.number
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first [limit] dates a [SimpleRecurrence] fires on, starting at [start]
|
||||||
|
* (DTSTART), for previewing a rule as dates instead of as words. A preview only,
|
||||||
|
* kept to the shapes the picker can build; the provider stays the authority.
|
||||||
|
*
|
||||||
|
* Mirrors RFC 5545: [start] is always the first occurrence (§3.8.5.3), even when
|
||||||
|
* the rule's own picks miss it; a monthly or yearly rule *skips* a period the
|
||||||
|
* start day doesn't exist in rather than clamping; a weekly rule repeats in
|
||||||
|
* blocks of `interval` weeks beginning on Monday (the default WKST, since
|
||||||
|
* [toRRule] never writes one); [RecurrenceEnd.Count] counts real occurrences and
|
||||||
|
* [RecurrenceEnd.Until] is inclusive.
|
||||||
|
*
|
||||||
|
* Returns fewer than [limit] dates when the series ends first, and an empty list
|
||||||
|
* only when the rule yields nothing at all (an UNTIL before [start]).
|
||||||
|
*/
|
||||||
|
fun SimpleRecurrence.upcomingOccurrences(start: LocalDate, limit: Int): List<LocalDate> {
|
||||||
|
if (limit <= 0) return emptyList()
|
||||||
|
val until = (end as? RecurrenceEnd.Until)?.date
|
||||||
|
val maxCount = (end as? RecurrenceEnd.Count)?.times ?: Int.MAX_VALUE
|
||||||
|
val wanted = minOf(limit, maxCount)
|
||||||
|
if (wanted <= 0) return emptyList()
|
||||||
|
if (until != null && start > until) return emptyList()
|
||||||
|
|
||||||
|
// DTSTART is in the recurrence set whatever the rule picks, so seed with it
|
||||||
|
// and let the walk skip anything landing on or before it.
|
||||||
|
val result = mutableListOf(start)
|
||||||
|
var period = 0
|
||||||
|
// Periods can yield nothing (a skipped 31st), so the cap counts periods
|
||||||
|
// examined rather than dates found.
|
||||||
|
while (result.size < wanted && period < MAX_PERIODS) {
|
||||||
|
for (date in occurrencesInPeriod(period, start)) {
|
||||||
|
if (date <= start) continue
|
||||||
|
if (until != null && date > until) return result
|
||||||
|
result += date
|
||||||
|
if (result.size == wanted) return result
|
||||||
|
}
|
||||||
|
period++
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dates this rule's [period]-th repetition yields (empty when skipped). */
|
||||||
|
private fun SimpleRecurrence.occurrencesInPeriod(period: Int, start: LocalDate): List<LocalDate> =
|
||||||
|
when (freq) {
|
||||||
|
RecurrenceFreq.Daily -> listOf(start.plus(period * interval, DateTimeUnit.DAY))
|
||||||
|
RecurrenceFreq.Weekly -> weeklyOccurrences(period, start)
|
||||||
|
RecurrenceFreq.Monthly -> {
|
||||||
|
val month = start.plus(period * interval, DateTimeUnit.MONTH)
|
||||||
|
// plus() clamps into the shorter month, but the rule skips such a
|
||||||
|
// period — so a clamped date means "not this month".
|
||||||
|
listOfNotNull(dateOrNull(month.year, month.month.number, start.day))
|
||||||
|
}
|
||||||
|
RecurrenceFreq.Yearly ->
|
||||||
|
listOfNotNull(dateOrNull(start.year + period * interval, start.month.number, start.day))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One weekly block: every picked weekday inside the week that begins
|
||||||
|
* `period * interval` weeks after the start's own week, in weekday order. With
|
||||||
|
* no picks the rule simply repeats the start's weekday.
|
||||||
|
*/
|
||||||
|
private fun SimpleRecurrence.weeklyOccurrences(period: Int, start: LocalDate): List<LocalDate> {
|
||||||
|
if (byDays.isEmpty()) return listOf(start.plus(period * interval, DateTimeUnit.WEEK))
|
||||||
|
val daysIntoWeek = (start.dayOfWeek.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) %
|
||||||
|
DAYS_PER_WEEK
|
||||||
|
val weekStart = start
|
||||||
|
.minus(daysIntoWeek, DateTimeUnit.DAY)
|
||||||
|
.plus(period * interval, DateTimeUnit.WEEK)
|
||||||
|
return byDays.sortedBy { it.isoDayNumber }.map { day ->
|
||||||
|
weekStart.plus(
|
||||||
|
(day.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) % DAYS_PER_WEEK,
|
||||||
|
DateTimeUnit.DAY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a run of [occurrences] starting at [start] leaves its starting year,
|
||||||
|
* i.e. whether showing them without a year would be ambiguous — a yearly rule
|
||||||
|
* would otherwise read as the same date repeated.
|
||||||
|
*/
|
||||||
|
fun occurrencesSpanYears(occurrences: List<LocalDate>, start: LocalDate): Boolean =
|
||||||
|
occurrences.any { it.year != start.year } || occurrences.map { it.year }.distinct().size > 1
|
||||||
|
|
||||||
|
/** [LocalDate] for a day-of-month that may not exist in that month; null if it doesn't. */
|
||||||
|
private fun dateOrNull(year: Int, month: Int, day: Int): LocalDate? =
|
||||||
|
runCatching { LocalDate(year, month, day) }.getOrNull()
|
||||||
|
|
||||||
|
private const val DAYS_PER_WEEK = 7
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many repetitions to examine before giving up: generous enough for the
|
||||||
|
* sparsest rule the picker can build, bounded so a rule whose occurrences all
|
||||||
|
* fall outside its own UNTIL can't spin.
|
||||||
|
*/
|
||||||
|
private const val MAX_PERIODS = 2_000
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import java.text.Normalizer
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.TextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One selectable zone, resolved for display at a given instant. [id] is the IANA
|
||||||
|
* id we store in `EVENT_TIMEZONE` and show as the primary label (via [label]);
|
||||||
|
* [shortName] its abbreviation ("CET", "WET", "UTC", or a "GMT+05:30" fallback);
|
||||||
|
* [offsetMinutes] its offset. Both the abbreviation and the offset shift with
|
||||||
|
* DST, so they're only meaningful next to the moment they were resolved for.
|
||||||
|
*
|
||||||
|
* [displayName] is the long localized name ("Central European Time"). It's kept
|
||||||
|
* for search only — matching on it lets someone type "pacific" — and is *not*
|
||||||
|
* displayed: spelled out next to the id it made the field too wide to fit.
|
||||||
|
*/
|
||||||
|
data class TimeZoneOption(
|
||||||
|
val id: String,
|
||||||
|
val displayName: String,
|
||||||
|
val shortName: String,
|
||||||
|
val offsetMinutes: Int,
|
||||||
|
) {
|
||||||
|
/** The id as the primary label, underscores undone ("America/New York"). */
|
||||||
|
val label: String get() = id.replace('_', ' ')
|
||||||
|
|
||||||
|
/** The trailing segment of the id ("Europe/Berlin" -> "Berlin"), underscores undone. */
|
||||||
|
val city: String get() = id.substringAfterLast('/').replace('_', ' ')
|
||||||
|
|
||||||
|
/** The leading segment ("Europe/Berlin" -> "Europe"); empty for bare ids like "UTC". */
|
||||||
|
val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four fields [filterTimeZones] matches on, normalized once here rather
|
||||||
|
* than per query. Normalizing is not cheap — NFD decomposition plus a combining-
|
||||||
|
* mark strip — and a search re-examines every option on every keystroke, so
|
||||||
|
* doing it at construction turns ~2400 normalizations per character into a few
|
||||||
|
* hundred plain `startsWith`/`contains` calls.
|
||||||
|
*
|
||||||
|
* Declared in the class body, so it stays out of `equals`/`hashCode`/`copy`:
|
||||||
|
* it is derived state, and two options with the same id are the same option.
|
||||||
|
*/
|
||||||
|
internal val searchKeys: TimeZoneSearchKeys = TimeZoneSearchKeys(
|
||||||
|
city = city.normalizeForSearch(),
|
||||||
|
id = id.normalizeForSearch(),
|
||||||
|
displayName = displayName.normalizeForSearch(),
|
||||||
|
shortName = shortName.normalizeForSearch(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pre-normalized match targets for one [TimeZoneOption]. */
|
||||||
|
internal data class TimeZoneSearchKeys(
|
||||||
|
val city: String,
|
||||||
|
val id: String,
|
||||||
|
val displayName: String,
|
||||||
|
val shortName: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun optionFor(
|
||||||
|
zone: ZoneId,
|
||||||
|
locale: Locale,
|
||||||
|
at: Instant,
|
||||||
|
regionOf: (String) -> String?,
|
||||||
|
): TimeZoneOption = TimeZoneOption(
|
||||||
|
id = zone.id,
|
||||||
|
displayName = zone.getDisplayName(TextStyle.FULL, locale),
|
||||||
|
shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf),
|
||||||
|
offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone abbreviation ("EDT", "CEST"), DST-correct at the resolved instant, or
|
||||||
|
* a "GMT+05:30" form when no name exists.
|
||||||
|
*
|
||||||
|
* Two things make this fiddlier than it looks. First, it goes through
|
||||||
|
* java.util.TimeZone, NOT java.time's "zzz" formatter: on Android the latter has
|
||||||
|
* no short specific-zone names and degrades every zone to a "GMT-4" form (it
|
||||||
|
* agrees with java.util only on desktop, which is why desktop can't catch it).
|
||||||
|
* Second, ICU only surfaces the short name *commonly used in the display
|
||||||
|
* locale's region* — a German-region English phone (en-DE) is shown "CEST" but
|
||||||
|
* not "EDT", and an en-US phone the reverse. So ask in the display *language*
|
||||||
|
* but the *zone's* region ("America/New_York" in en-US, "Europe/Berlin" in
|
||||||
|
* en-DE) via [regionOf]. That covers almost everything; where a region still
|
||||||
|
* yields no name (Athens in en-GR) the plain device locale sometimes does, so
|
||||||
|
* try that next; failing both, the caller shows the offset.
|
||||||
|
*
|
||||||
|
* [regionOf] maps an IANA id to an ISO 3166 region and is injected because it
|
||||||
|
* needs `android.icu`, which this pure-JVM module can't import. The default
|
||||||
|
* (no region) leaves only the device-locale path — which is all a JVM test has
|
||||||
|
* anyway, and enough there since desktop ICU isn't region-gated the same way.
|
||||||
|
*/
|
||||||
|
private fun resolveAbbreviation(
|
||||||
|
id: String,
|
||||||
|
dst: Boolean,
|
||||||
|
locale: Locale,
|
||||||
|
regionOf: (String) -> String?,
|
||||||
|
): String {
|
||||||
|
fun shortIn(loc: Locale): String =
|
||||||
|
java.util.TimeZone.getTimeZone(id).getDisplayName(dst, java.util.TimeZone.SHORT, loc)
|
||||||
|
val regional = regionOf(id)
|
||||||
|
?.takeIf { it.length == 2 }
|
||||||
|
?.let { shortIn(Locale(locale.language, it)) }
|
||||||
|
if (regional != null && !regional.looksLikeOffset()) return regional
|
||||||
|
// Either no region, or the region has no name for this zone: the device
|
||||||
|
// locale is the next-best shot, and the offset the last resort.
|
||||||
|
return shortIn(locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for the offset-style names ICU returns when a zone has no abbreviation. */
|
||||||
|
private fun String.looksLikeOffset(): Boolean = this == "UTC" || startsWith("GMT")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every zone the JVM knows, resolved at [at]. [regionOf] (see
|
||||||
|
* [resolveAbbreviation]) supplies the zone's region for the abbreviation.
|
||||||
|
*
|
||||||
|
* This is ~600 entries, each costing a localized display name plus up to two ICU
|
||||||
|
* short-name lookups, so it is **not** cheap enough for the main thread — build
|
||||||
|
* it off-thread once and filter the result with [filterTimeZones] rather than
|
||||||
|
* rebuilding per keystroke.
|
||||||
|
*
|
||||||
|
* Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are
|
||||||
|
* dropped: they're aliases the tz database keeps for compatibility, they'd
|
||||||
|
* double up the real zones in the list, and none of them is what a user means
|
||||||
|
* when they pick a place.
|
||||||
|
*/
|
||||||
|
fun timeZoneOptions(
|
||||||
|
locale: Locale = Locale.getDefault(),
|
||||||
|
at: Instant = Instant.now(),
|
||||||
|
regionOf: (String) -> String? = { null },
|
||||||
|
): List<TimeZoneOption> = ZoneId.getAvailableZoneIds()
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.contains('/') && !it.startsWith("SystemV/") }
|
||||||
|
.map { optionFor(ZoneId.of(it), locale, at, regionOf) }
|
||||||
|
.sortedWith(compareBy({ it.region }, { it.city }))
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a single [id] the same way [timeZoneOptions] would, or null if the tz
|
||||||
|
* database doesn't know it — for labelling one known zone without paying to
|
||||||
|
* build the whole catalogue.
|
||||||
|
*/
|
||||||
|
fun timeZoneOptionOf(
|
||||||
|
id: String,
|
||||||
|
locale: Locale = Locale.getDefault(),
|
||||||
|
at: Instant = Instant.now(),
|
||||||
|
regionOf: (String) -> String? = { null },
|
||||||
|
): TimeZoneOption? {
|
||||||
|
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
|
||||||
|
return optionFor(zone, locale, at, regionOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The short descriptor shown beneath the id, e.g. "CET · GMT+01:00". "UTC" reads
|
||||||
|
* fine alone; any other offset-shaped name is normalised to our own GMT form so
|
||||||
|
* the offset isn't stated twice in ICU's spelling and ours.
|
||||||
|
*/
|
||||||
|
fun zoneDescriptor(option: TimeZoneOption): String {
|
||||||
|
val abbrev = option.shortName
|
||||||
|
return when {
|
||||||
|
abbrev == "UTC" -> "UTC"
|
||||||
|
abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes)
|
||||||
|
else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [options] matching [query], best matches first; a blank query returns the list
|
||||||
|
* unchanged. Matching is accent- and case-insensitive and treats underscores as
|
||||||
|
* spaces, so "sao paulo" finds "America/Sao_Paulo".
|
||||||
|
*
|
||||||
|
* Ranking puts a city that *starts with* the query above one that merely
|
||||||
|
* contains it — typing "col" should reach Colombo before Turks_and_Caicos —
|
||||||
|
* and the id is matched ahead of the localized name so a user who knows the
|
||||||
|
* IANA id gets it first. An exact abbreviation hit ("CEST") ranks just under a
|
||||||
|
* city prefix, so typing an abbreviation surfaces every zone that shows it
|
||||||
|
* (all the CEST zones together); the abbreviation is the one resolved for the
|
||||||
|
* display region, i.e. what the row actually shows.
|
||||||
|
*/
|
||||||
|
fun filterTimeZones(options: List<TimeZoneOption>, query: String): List<TimeZoneOption> {
|
||||||
|
val needle = query.normalizeForSearch()
|
||||||
|
if (needle.isEmpty()) return options
|
||||||
|
return options
|
||||||
|
.mapNotNull { option ->
|
||||||
|
val keys = option.searchKeys
|
||||||
|
val rank = when {
|
||||||
|
keys.city.startsWith(needle) -> 0
|
||||||
|
keys.shortName == needle -> 1
|
||||||
|
keys.displayName.startsWith(needle) -> 2
|
||||||
|
keys.shortName.startsWith(needle) -> 3
|
||||||
|
keys.city.contains(needle) -> 4
|
||||||
|
keys.id.contains(needle) -> 5
|
||||||
|
keys.displayName.contains(needle) -> 6
|
||||||
|
else -> return@mapNotNull null
|
||||||
|
}
|
||||||
|
rank to option
|
||||||
|
}
|
||||||
|
.sortedWith(compareBy({ it.first }, { it.second.city }))
|
||||||
|
.map { it.second }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unicode combining marks — what NFD decomposition leaves an accent as. */
|
||||||
|
private val COMBINING_MARKS = Regex("\\p{Mn}+")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lowercased, accent-stripped, underscores and slashes flattened to spaces, so
|
||||||
|
* a query types the way a place is spoken rather than the way the tz database
|
||||||
|
* spells it.
|
||||||
|
*/
|
||||||
|
private fun String.normalizeForSearch(): String =
|
||||||
|
Normalizer.normalize(this, Normalizer.Form.NFD)
|
||||||
|
.replace(COMBINING_MARKS, "")
|
||||||
|
.replace('_', ' ')
|
||||||
|
.replace('/', ' ')
|
||||||
|
.lowercase(Locale.ROOT)
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
/** "GMT+02:00" / "GMT-05:30" / "GMT" — the offset as shown next to a zone. */
|
||||||
|
fun formatGmtOffset(offsetMinutes: Int): String {
|
||||||
|
if (offsetMinutes == 0) return "GMT"
|
||||||
|
val sign = if (offsetMinutes < 0) '-' else '+'
|
||||||
|
val abs = kotlin.math.abs(offsetMinutes)
|
||||||
|
return "GMT%c%02d:%02d".format(sign, abs / 60, abs % 60)
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.reminders
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decision layer of in-house reminder delivery (#75): instances and reminder
|
||||||
|
* offsets in, fire instants out. No provider, no clock. See docs/ARCHITECTURE.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** An occurrence that reminders can hang off, flattened out of `Instances`. */
|
||||||
|
data class ReminderEventInstance(
|
||||||
|
val eventId: Long,
|
||||||
|
val calendarId: Long,
|
||||||
|
val beginMillis: Long,
|
||||||
|
val endMillis: Long,
|
||||||
|
val title: String,
|
||||||
|
val location: String?,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One occurrence paired with one of its reminder offsets, and the instant that
|
||||||
|
* pairing has to fire at.
|
||||||
|
*/
|
||||||
|
data class PlannedReminder(
|
||||||
|
val instance: ReminderEventInstance,
|
||||||
|
val minutes: Int,
|
||||||
|
val alarmMillis: Long,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Stable identity, keying the notification tag and the snooze/dismiss
|
||||||
|
* `PendingIntent`s. Survives reboot, re-scan and reinstall.
|
||||||
|
*/
|
||||||
|
val key: Long = key(instance.eventId, instance.beginMillis, minutes)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun key(eventId: Long, beginMillis: Long, minutes: Int): Long {
|
||||||
|
var h = eventId * 1_000_003L
|
||||||
|
h = (h xor beginMillis) * 31L
|
||||||
|
return h + minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What one scan concluded: post these now, and wake up again at [nextAlarmMillis]. */
|
||||||
|
data class ReminderSchedule(
|
||||||
|
val due: List<PlannedReminder>,
|
||||||
|
val nextAlarmMillis: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val MILLIS_PER_MINUTE = 60_000L
|
||||||
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pair every instance with each of its event's reminder offsets.
|
||||||
|
*
|
||||||
|
* Timed occurrences fire at `begin − minutes`. All-day ones read the offset only
|
||||||
|
* for *which day* it means ([allDayLeadDays]) and take the hour from
|
||||||
|
* [allDayTimeMinutes], recomposed against each occurrence's own date in [zone].
|
||||||
|
* Duplicate offsets in [minutesByEvent] collapse.
|
||||||
|
*/
|
||||||
|
fun planReminders(
|
||||||
|
instances: List<ReminderEventInstance>,
|
||||||
|
minutesByEvent: Map<Long, List<Int>>,
|
||||||
|
zone: ZoneId,
|
||||||
|
allDayTimeMinutes: Int,
|
||||||
|
): List<PlannedReminder> = instances.flatMap { instance ->
|
||||||
|
minutesByEvent[instance.eventId].orEmpty().distinct().map { minutes ->
|
||||||
|
PlannedReminder(
|
||||||
|
instance = instance,
|
||||||
|
minutes = minutes,
|
||||||
|
alarmMillis = if (instance.isAllDay) {
|
||||||
|
allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes)
|
||||||
|
} else {
|
||||||
|
instance.beginMillis - minutes * MILLIS_PER_MINUTE
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTC midnight of an all-day occurrence, as the calendar date it stands for. */
|
||||||
|
private fun allDayDate(beginMillis: Long): LocalDate =
|
||||||
|
Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many whole days before its occurrence a raw all-day offset means.
|
||||||
|
*
|
||||||
|
* Our own rows fold the wanted hour into the offset, so the local date of the
|
||||||
|
* encoded instant is the answer. A plain multiple of 1440 is a foreign bare lead
|
||||||
|
* time and taken at face value instead — unless the instant lands on the hour the
|
||||||
|
* setting names (within [NAMED_HOUR_TOLERANCE_MINUTES], for DST drift), where the
|
||||||
|
* encodings collide and the tie goes to our own reading.
|
||||||
|
*
|
||||||
|
* Also used by
|
||||||
|
* [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes] for
|
||||||
|
* display, so screen and notification agree.
|
||||||
|
*/
|
||||||
|
internal fun allDayLeadDays(
|
||||||
|
rawMinutes: Int,
|
||||||
|
startDate: LocalDate,
|
||||||
|
zone: ZoneId,
|
||||||
|
allDayTimeMinutes: Int,
|
||||||
|
): Long {
|
||||||
|
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
val encoded = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE).atZone(zone)
|
||||||
|
val minutesFromNamedHour = encoded.toLocalTime().let {
|
||||||
|
val delta = (it.hour * 60 + it.minute - allDayTimeMinutes).mod(MINUTES_PER_DAY)
|
||||||
|
minOf(delta, MINUTES_PER_DAY - delta)
|
||||||
|
}
|
||||||
|
if (rawMinutes % MINUTES_PER_DAY == 0 && minutesFromNamedHour > NAMED_HOUR_TOLERANCE_MINUTES) {
|
||||||
|
return (rawMinutes / MINUTES_PER_DAY).toLong()
|
||||||
|
}
|
||||||
|
return ChronoUnit.DAYS.between(encoded.toLocalDate(), startDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DST drift a row written in the other phase carries, rounded up past Lord Howe's half hour. */
|
||||||
|
private const val NAMED_HOUR_TOLERANCE_MINUTES = 90
|
||||||
|
|
||||||
|
private fun allDayAlarmMillis(
|
||||||
|
beginMillis: Long,
|
||||||
|
rawMinutes: Int,
|
||||||
|
zone: ZoneId,
|
||||||
|
allDayTimeMinutes: Int,
|
||||||
|
): Long = allDayDate(beginMillis)
|
||||||
|
.minusDays(allDayLeadDays(rawMinutes, allDayDate(beginMillis), zone, allDayTimeMinutes))
|
||||||
|
.atTime(LocalTime.of(allDayTimeMinutes / 60, allDayTimeMinutes % 60))
|
||||||
|
.atZone(zone)
|
||||||
|
.toInstant()
|
||||||
|
.toEpochMilli()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split [planned] into what is due now and when to wake up next.
|
||||||
|
*
|
||||||
|
* Due means the fire instant falls in `(lastFiredMillis, nowMillis]`, so a scan
|
||||||
|
* running twice cannot post twice while a late one still catches up. Reminders
|
||||||
|
* whose event has ended are dropped ([isStillRelevant]). [nextAlarmMillis] is
|
||||||
|
* capped at [horizonMillis] so the lookahead window keeps rolling forward.
|
||||||
|
*/
|
||||||
|
fun scheduleReminders(
|
||||||
|
planned: List<PlannedReminder>,
|
||||||
|
lastFiredMillis: Long,
|
||||||
|
nowMillis: Long,
|
||||||
|
horizonMillis: Long,
|
||||||
|
): ReminderSchedule {
|
||||||
|
val due = planned
|
||||||
|
.filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis }
|
||||||
|
.filter { it.instance.isStillRelevant(nowMillis) }
|
||||||
|
.distinctBy { it.key }
|
||||||
|
.sortedWith(compareBy({ it.instance.beginMillis }, { it.key }))
|
||||||
|
val nextPending = planned
|
||||||
|
.filter { it.alarmMillis > nowMillis }
|
||||||
|
.minOfOrNull { it.alarmMillis }
|
||||||
|
return ReminderSchedule(
|
||||||
|
due = due,
|
||||||
|
nextAlarmMillis = minOf(nextPending ?: horizonMillis, horizonMillis),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Still worth showing while the occurrence has not ended. Falls back to the
|
||||||
|
* begin time when the end is unknown (0L).
|
||||||
|
*/
|
||||||
|
fun ReminderEventInstance.isStillRelevant(nowMillis: Long): Boolean =
|
||||||
|
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The watermark a scan at [nowMillis] should measure against. A first-ever scan
|
||||||
|
* claims the present rather than replaying everything since the epoch; a
|
||||||
|
* watermark in the future (clock moved back) is clamped so it can't silence
|
||||||
|
* every reminder until real time catches up.
|
||||||
|
*/
|
||||||
|
fun reminderWatermark(lastScanMillis: Long?, nowMillis: Long): Long =
|
||||||
|
lastScanMillis?.coerceAtMost(nowMillis) ?: nowMillis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far ahead instances must be queried: the plain lookahead plus the longest
|
||||||
|
* reminder offset, so a "two weeks before" is planned before it comes due. The
|
||||||
|
* stretch is capped at [MAX_REMINDER_LEAD_MILLIS] — `maxReminderMinutes` is the
|
||||||
|
* largest row in the whole provider, and a nonsense one would otherwise make
|
||||||
|
* every scan expand every series over years.
|
||||||
|
*/
|
||||||
|
fun reminderQueryHorizon(lookaheadMillis: Long, maxReminderMinutes: Int): Long =
|
||||||
|
lookaheadMillis + (maxReminderMinutes * MILLIS_PER_MINUTE).coerceIn(0L, MAX_REMINDER_LEAD_MILLIS)
|
||||||
|
|
||||||
|
/** Longest reminder offset a scan stretches its query window for — one year. */
|
||||||
|
const val MAX_REMINDER_LEAD_MILLIS = 365L * 24 * 60 * 60 * 1000
|
||||||
@@ -23,6 +23,7 @@ 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.floret.identity.fadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
@@ -88,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))
|
||||||
@@ -146,6 +149,10 @@ 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) — hoisted like the manager, being driven by the
|
||||||
|
// calendar list rather than by preferences. Reached from both.
|
||||||
|
var showBackup by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
// Event form (v1.2 create) — same held-key pattern as the detail screen:
|
// 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)
|
||||||
@@ -208,6 +215,7 @@ 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
|
||||||
@@ -291,8 +299,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)
|
||||||
}
|
}
|
||||||
@@ -318,6 +326,7 @@ fun CalendarHost(
|
|||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
drawerViewOrder = drawerViewOrder,
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
CalendarView.Day -> DayScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
@@ -329,16 +338,19 @@ 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,
|
||||||
|
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(
|
CalendarView.Agenda -> AgendaScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
@@ -350,6 +362,7 @@ fun CalendarHost(
|
|||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
drawerViewOrder = drawerViewOrder,
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,6 +397,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
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,6 +421,7 @@ fun CalendarHost(
|
|||||||
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||||
onClose = { createDateIso = null },
|
onClose = { createDateIso = null },
|
||||||
onSaved = { createDateIso = null },
|
onSaved = { createDateIso = null },
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -419,6 +441,7 @@ fun CalendarHost(
|
|||||||
editKey = null
|
editKey = null
|
||||||
detailKey = null
|
detailKey = null
|
||||||
},
|
},
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -432,18 +455,7 @@ 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 },
|
|
||||||
onImport = { importUri = it; importForceMany = true },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,6 +466,7 @@ fun CalendarHost(
|
|||||||
uri = uri,
|
uri = uri,
|
||||||
forceMany = importForceMany,
|
forceMany = importForceMany,
|
||||||
onClose = { importUri = null },
|
onClose = { importUri = null },
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
onOpenSingle = { form ->
|
onOpenSingle = { form ->
|
||||||
importUri = null
|
importUri = null
|
||||||
importFormSource = ImportSource.File
|
importFormSource = ImportSource.File
|
||||||
@@ -468,6 +481,40 @@ fun CalendarHost(
|
|||||||
initialFormSource = importFormSource,
|
initialFormSource = importFormSource,
|
||||||
onClose = { importForm = null },
|
onClose = { importForm = null },
|
||||||
onSaved = { importForm = null },
|
onSaved = { importForm = null },
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declared last so it covers every overlay that can open it: Settings,
|
||||||
|
// both event forms, and the .ics import picker (#76).
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showCalendars,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
CalendarsScreen(
|
||||||
|
onBack = { showCalendars = false },
|
||||||
|
onOpenBackup = { showBackup = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup & restore — over the manager, since the manager links into it.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showBackup,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
BackupScreen(
|
||||||
|
onBack = { showBackup = false },
|
||||||
|
// Restore runs the normal .ics import, and both this screen and
|
||||||
|
// the manager that can have opened it are declared above the
|
||||||
|
// import overlays — so both have to step aside.
|
||||||
|
onImport = {
|
||||||
|
importUri = it
|
||||||
|
importForceMany = true
|
||||||
|
showBackup = false
|
||||||
|
showCalendars = false
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,4 +45,12 @@ class CalendarHostViewModel @Inject constructor(
|
|||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
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
|
||||||
@@ -48,6 +51,10 @@ fun RootScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A launch scan already covers the app coming up with the permission, so
|
||||||
|
// only a grant made during this session owes a re-scan.
|
||||||
|
val grantedAtLaunch = remember { hasPermission }
|
||||||
|
|
||||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
DisposableEffect(lifecycle) {
|
DisposableEffect(lifecycle) {
|
||||||
val obs = LifecycleEventObserver { _, event ->
|
val obs = LifecycleEventObserver { _, event ->
|
||||||
@@ -78,6 +85,19 @@ 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.
|
||||||
|
val visibilityNotice: CalendarVisibilityNoticeViewModel = hiltViewModel()
|
||||||
|
val noticePending by visibilityNotice.pending.collectAsStateWithLifecycle()
|
||||||
|
// Runs on entry however the permission was granted, including via
|
||||||
|
// Android's app-settings screen (caught by the ON_RESUME above).
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
visibilityNotice.reconcile()
|
||||||
|
if (!grantedAtLaunch) reminderOnboarding.rearmAfterGrant()
|
||||||
|
}
|
||||||
|
if (onboardingDone == true && noticePending) {
|
||||||
|
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
|
||||||
|
}
|
||||||
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
||||||
when (done) {
|
when (done) {
|
||||||
true -> CalendarHost(
|
true -> CalendarHost(
|
||||||
|
|||||||
@@ -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,24 +86,37 @@ 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.
|
||||||
|
*
|
||||||
|
* [monthAsSpan] puts [AgendaRange.ThisMonth] on the "start – end" form as well.
|
||||||
|
* The agenda's own header browses the whole month, so the month name is right
|
||||||
|
* there; the range picker previews the window an option opens *today*, which for
|
||||||
|
* "This month" is only the rest of it.
|
||||||
*/
|
*/
|
||||||
fun agendaRangeWindowSummary(
|
fun agendaRangeWindowSummary(
|
||||||
range: AgendaRange,
|
range: AgendaRange,
|
||||||
start: LocalDate,
|
start: LocalDate,
|
||||||
end: LocalDate,
|
end: LocalDate,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
monthAsSpan: Boolean = false,
|
||||||
): 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")
|
||||||
return when (range) {
|
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
|
||||||
AgendaRange.Day -> medium.format(javaStart)
|
return when {
|
||||||
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
|
range == AgendaRange.Day -> dayMonthYear.format(javaStart)
|
||||||
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} – ${medium.format(javaEnd)}"
|
range == AgendaRange.ThisMonth && !monthAsSpan ->
|
||||||
|
localizedDateFormatter(locale, "LLLLy").format(javaStart)
|
||||||
|
else -> {
|
||||||
|
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
|
||||||
|
"${startFmt.format(javaStart)} – ${dayMonthYear.format(javaEnd)}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Coffee
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
// The agenda's row vocabulary, split out of AgendaScreen so the month view's
|
||||||
|
// split style can list a day with exactly the same visual language instead of
|
||||||
|
// growing a parallel set of event rows.
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AgendaDayHeader(
|
||||||
|
date: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onOpenDay(date) },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = agendaDayLabel(date, today),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = if (date == today) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A card standing in for a day with no events — the same coffee-cup motif as the
|
||||||
|
* agenda's full-screen empty state, boxed into a card so the day keeps a visible slot
|
||||||
|
* rather than a bare header. Used for an anchored, event-less today (#35) and for
|
||||||
|
* an empty selected day in the month view's split style.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AgendaEmptyDayRow(text: String, onClick: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
// Match a single event row's resting corner radius (floret groupedShape).
|
||||||
|
shape = RoundedCornerShape(22.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 18.dp, horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Coffee,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AgendaEventRow(
|
||||||
|
event: EventInstance,
|
||||||
|
day: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
|
position: Position,
|
||||||
|
dimmed: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
GroupedRow(
|
||||||
|
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||||
|
title = title,
|
||||||
|
summary = agendaTimeSummary(event, day, zone),
|
||||||
|
position = position,
|
||||||
|
minHeight = 64.dp,
|
||||||
|
leading = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 6.dp, height = 36.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp))
|
||||||
|
.background(eventFill(event.color, dark, soften)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
||||||
|
@Composable
|
||||||
|
internal fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
||||||
|
val relative = when (date) {
|
||||||
|
today -> stringResource(R.string.agenda_header_today)
|
||||||
|
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val formatted = formatAgendaDate(date)
|
||||||
|
return if (relative != null) "$relative · $formatted" else formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time line under the title: "09:00 – 10:00 · Location", "All day", etc.
|
||||||
|
*
|
||||||
|
* A multi-day event shows only the part relevant to [day], spelled out so each
|
||||||
|
* day reads on its own: its first day names the start ("Starts 14:00"), its last
|
||||||
|
* day the end ("Ends 10:00"), and any whole day in between reads as "All day".
|
||||||
|
* An all-day multi-day event is simply "All day" on every day it covers.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String {
|
||||||
|
val is24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
|
||||||
|
val time = when (val label = agendaTimeLabel(event, day, zone)) {
|
||||||
|
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
|
||||||
|
is AgendaTimeLabel.Starts -> stringResource(
|
||||||
|
R.string.agenda_span_starts,
|
||||||
|
formatTime(label.start, zone, is24Hour, locale),
|
||||||
|
)
|
||||||
|
is AgendaTimeLabel.Ends -> stringResource(
|
||||||
|
R.string.agenda_span_ends,
|
||||||
|
formatTime(label.end, zone, is24Hour, locale),
|
||||||
|
)
|
||||||
|
is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " +
|
||||||
|
formatTime(label.end, zone, is24Hour, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
|
return if (location != null) "$time · $location" else time
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(
|
||||||
|
instant: Instant,
|
||||||
|
zone: TimeZone,
|
||||||
|
is24Hour: Boolean,
|
||||||
|
locale: Locale,
|
||||||
|
): String {
|
||||||
|
val t = instant.toLocalDateTime(zone).time
|
||||||
|
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatAgendaDate(date: LocalDate): String {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||||
|
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
|
||||||
|
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
|
||||||
|
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.agenda
|
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
|
||||||
@@ -62,31 +55,23 @@ import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
|||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
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.floret.components.GroupedRow
|
|
||||||
import de.jeanlucmakiola.floret.components.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.floret.components.pastelize
|
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.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
|
||||||
@@ -100,12 +85,15 @@ fun AgendaScreen(
|
|||||||
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)
|
||||||
@@ -148,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) },
|
||||||
@@ -170,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,
|
||||||
@@ -190,6 +182,7 @@ 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,
|
onOpenDay = onOpenDay,
|
||||||
@@ -206,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 },
|
||||||
)
|
)
|
||||||
@@ -213,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(
|
||||||
@@ -224,43 +220,34 @@ private fun AgendaRangePill(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val container = if (isOverride) {
|
FilledTonalButton(
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
onClick = onClick,
|
||||||
} else {
|
shape = MaterialTheme.shapes.large,
|
||||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
colors = ButtonDefaults.filledTonalButtonColors(
|
||||||
}
|
containerColor = if (isOverride) {
|
||||||
val content = if (isOverride) {
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
MaterialTheme.colorScheme.onPrimaryContainer
|
} else {
|
||||||
} else {
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
},
|
||||||
}
|
contentColor = if (isOverride) {
|
||||||
Surface(
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
color = container,
|
} else {
|
||||||
contentColor = content,
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
shape = RoundedCornerShape(50),
|
},
|
||||||
modifier = modifier.clickable(onClick = onClick),
|
),
|
||||||
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Text(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
text = agendaRangeLabel(range),
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
style = MaterialTheme.typography.labelLarge,
|
||||||
) {
|
)
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.DateRange,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = agendaRangeLabel(range),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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(
|
||||||
@@ -277,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,
|
||||||
)
|
)
|
||||||
@@ -289,6 +278,7 @@ 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,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
@@ -304,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)
|
||||||
@@ -312,12 +302,21 @@ 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,
|
||||||
@@ -334,6 +333,7 @@ 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,
|
||||||
@@ -349,76 +349,38 @@ private fun AgendaList(
|
|||||||
stickyHeader(key = "header-${day.date}") {
|
stickyHeader(key = "header-${day.date}") {
|
||||||
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
||||||
}
|
}
|
||||||
itemsIndexed(
|
if (day.events.isEmpty()) {
|
||||||
items = day.events,
|
// An anchored, event-less today (#35) — "nothing left today".
|
||||||
key = { _, event -> event.instanceId },
|
item(key = "placeholder-${day.date}") {
|
||||||
) { index, event ->
|
AgendaEmptyDayRow(
|
||||||
AgendaEventRow(
|
text = stringResource(R.string.agenda_no_more_today),
|
||||||
event = event,
|
onClick = { onOpenDay(day.date) },
|
||||||
position = positionOf(index, day.events.size),
|
)
|
||||||
dimmed = dimPast && event.hasEnded(now),
|
}
|
||||||
modifier = animateItemMotion(),
|
} else {
|
||||||
onClick = { onEventClick(event) },
|
itemsIndexed(
|
||||||
)
|
items = day.events,
|
||||||
|
// Scope the key by day: a multi-day event appears under every
|
||||||
|
// day it spans, so its instanceId alone is not unique across
|
||||||
|
// the list (LazyColumn requires unique keys).
|
||||||
|
key = { _, event -> "${day.date}-${event.instanceId}" },
|
||||||
|
) { index, event ->
|
||||||
|
AgendaEventRow(
|
||||||
|
event = event,
|
||||||
|
day = day.date,
|
||||||
|
zone = zone,
|
||||||
|
position = positionOf(index, day.events.size),
|
||||||
|
dimmed = dimPast && event.hasEnded(now),
|
||||||
|
modifier = animateItemMotion(),
|
||||||
|
onClick = { onEventClick(event) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private 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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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(
|
||||||
@@ -448,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(
|
||||||
@@ -466,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,
|
||||||
@@ -485,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,31 +49,69 @@ 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)
|
||||||
AgendaDay(
|
// Skip instances that don't actually occupy any day in [[anchor], [windowEnd]].
|
||||||
date = date,
|
// The provider returns an event whenever its instant span overlaps the query
|
||||||
events = dayEvents.sortedWith(
|
// window, but all-day events live at UTC midnights with an exclusive end: east
|
||||||
compareByDescending<EventInstance> { it.isAllDay }
|
// of UTC that end dips just past local midnight, so *yesterday's* all-day event
|
||||||
.thenBy { it.start }
|
// overlaps today's window start and comes back even though its true last day
|
||||||
.thenBy { it.title },
|
// (resolved in UTC) is before the anchor. Clamping it up to the anchor would
|
||||||
),
|
// surface it under "today" (issue #65); drop it instead. The symmetric case —
|
||||||
)
|
// a next-day all-day event overlapping the window's last instant — drops too.
|
||||||
|
if (lastDay < firstDay) continue
|
||||||
|
var day = firstDay
|
||||||
|
while (day <= lastDay) {
|
||||||
|
byDay.getOrPut(day) { mutableListOf() }.add(instance)
|
||||||
|
day = day.plus(1, DateTimeUnit.DAY)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return byDay.map { (date, dayEvents) ->
|
||||||
|
AgendaDay(
|
||||||
|
date = date,
|
||||||
|
events = dayEvents.sortedWith(
|
||||||
|
compareByDescending<EventInstance> { it.isAllDay }
|
||||||
|
.thenBy { it.start }
|
||||||
|
.thenBy { it.title },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure [today] surfaces as the first agenda day even when it carries no
|
||||||
|
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget
|
||||||
|
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and
|
||||||
|
* when today already has its own day in [days]. Keeps today anchored at the top
|
||||||
|
* so a glance tells today's events apart from a future day's (issue #35).
|
||||||
|
*/
|
||||||
|
fun anchorTodayIfMissing(
|
||||||
|
days: List<AgendaDay>,
|
||||||
|
today: LocalDate,
|
||||||
|
enabled: Boolean,
|
||||||
|
): List<AgendaDay> =
|
||||||
|
if (enabled && days.none { it.date == today }) {
|
||||||
|
listOf(AgendaDay(today, emptyList())) + days
|
||||||
|
} else {
|
||||||
|
days
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
* 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,373 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.calendars
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.FileDownload
|
||||||
|
import androidx.compose.material.icons.filled.FileUpload
|
||||||
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringArrayResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
// SAF mime filter for the restore picker. Source apps hand `.ics` files out
|
||||||
|
// under several mimes, so accept the common set.
|
||||||
|
private val RESTORE_MIME_TYPES = arrayOf(
|
||||||
|
"text/calendar",
|
||||||
|
"application/octet-stream",
|
||||||
|
"text/plain",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup & restore (#69): `.ics` export and restore for local calendars, with
|
||||||
|
* optional automatic backup. Shares [CalendarsViewModel] with the manager.
|
||||||
|
*
|
||||||
|
* A full-screen destination hoisted in `CalendarHost`; [onBack] pops it,
|
||||||
|
* [onImport] hands a picked file to the app's normal .ics import flow.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BackupScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onImport: (Uri) -> Unit,
|
||||||
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
|
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||||
|
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
// Export covers local calendars only; managed special-dates mirrors are
|
||||||
|
// rebuilt from contacts. Restore can target anything the import picker offers.
|
||||||
|
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||||
|
val canImport = calendars.any { it.isEventTarget }
|
||||||
|
|
||||||
|
// Exports everything eligible (null); the per-calendar selector owns its
|
||||||
|
// own launcher.
|
||||||
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||||
|
) { uri -> uri?.let { viewModel.exportBackup(it, null) } }
|
||||||
|
var showExportPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Restore runs the picked file through the normal .ics import flow.
|
||||||
|
val openBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocument(),
|
||||||
|
) { uri -> uri?.let(onImport) }
|
||||||
|
|
||||||
|
// The VM persists the write grant so background runs can keep writing.
|
||||||
|
val pickFolder = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||||
|
) { uri -> uri?.let(viewModel::setAutoBackupFolder) }
|
||||||
|
var showInterval by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||||
|
LaunchedEffect(backupResult) {
|
||||||
|
when (val r = backupResult) {
|
||||||
|
is BackupResult.Success -> {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
context.resources.getQuantityString(
|
||||||
|
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
viewModel.consumeBackupResult()
|
||||||
|
}
|
||||||
|
BackupResult.Failure -> {
|
||||||
|
snackbarHostState.showSnackbar(backupFailedText)
|
||||||
|
viewModel.consumeBackupResult()
|
||||||
|
}
|
||||||
|
null -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_section_backup),
|
||||||
|
onBack = onBack,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
HintText(stringResource(R.string.calendars_backup_hint))
|
||||||
|
|
||||||
|
if (exportable.isNotEmpty()) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_backup_action),
|
||||||
|
position = Position.Top,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||||
|
onClick = {
|
||||||
|
// A single exportable calendar skips the selector.
|
||||||
|
if (exportable.size == 1) {
|
||||||
|
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||||
|
} else {
|
||||||
|
showExportPicker = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
summary = stringResource(R.string.calendars_restore_hint),
|
||||||
|
position = Position.Middle,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup),
|
||||||
|
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||||
|
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||||
|
trailing = {
|
||||||
|
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
|
||||||
|
)
|
||||||
|
if (autoBackup.enabled) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup_folder),
|
||||||
|
summary = rememberFolderName(autoBackup.folderUri)
|
||||||
|
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { runCatching { pickFolder.launch(null) } },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||||
|
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showInterval = true },
|
||||||
|
)
|
||||||
|
HintText(backupStatusText(autoBackup.status))
|
||||||
|
}
|
||||||
|
} else if (canImport) {
|
||||||
|
// Nothing to back up, but restore is still possible — don't hide
|
||||||
|
// it behind export eligibility.
|
||||||
|
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||||
|
HintText(stringResource(R.string.calendars_restore_hint))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showExportPicker) {
|
||||||
|
ExportCalendarPicker(
|
||||||
|
calendars = exportable,
|
||||||
|
onExport = viewModel::exportBackup,
|
||||||
|
onDismiss = { showExportPicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showInterval) {
|
||||||
|
BackupIntervalDialog(
|
||||||
|
currentMinutes = autoBackup.intervalMinutes,
|
||||||
|
onConfirm = viewModel::setAutoBackupIntervalMinutes,
|
||||||
|
onDismiss = { showInterval = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
||||||
|
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||||
|
* the picked file with the chosen calendar ids.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ExportCalendarPicker(
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
onExport: (Uri, Set<Long>?) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Deliberately not keyed on [calendars]: that list is observer-driven, so
|
||||||
|
// keying it would reset the user's de-selections on every provider re-emit.
|
||||||
|
var selected by rememberSaveable(
|
||||||
|
stateSaver = listSaver(
|
||||||
|
save = { it.toList() },
|
||||||
|
restore = { it.toSet() },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
mutableStateOf(calendars.map { it.id }.toSet())
|
||||||
|
}
|
||||||
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
onExport(uri, selected)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.calendars_export_title),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
) {
|
||||||
|
HintText(stringResource(R.string.calendars_export_hint))
|
||||||
|
calendars.forEachIndexed { index, calendar ->
|
||||||
|
val isSelected = calendar.id in selected
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
summary = calendar.description,
|
||||||
|
position = positionOf(index, calendars.size),
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = {
|
||||||
|
Checkbox(
|
||||||
|
checked = isSelected,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
selected = if (checked) selected + calendar.id else selected - calendar.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
selected = if (isSelected) selected - calendar.id else selected + calendar.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||||
|
},
|
||||||
|
enabled = selected.isNotEmpty(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.calendars_export_action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
||||||
|
@Composable
|
||||||
|
private fun rememberFolderName(uriString: String?): String? {
|
||||||
|
val context = LocalContext.current
|
||||||
|
return remember(uriString) {
|
||||||
|
uriString?.let {
|
||||||
|
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
|
||||||
|
@Composable
|
||||||
|
private fun backupIntervalLabel(minutes: Long): String {
|
||||||
|
val duration = when {
|
||||||
|
minutes % MINUTES_PER_WEEK == 0L ->
|
||||||
|
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
|
||||||
|
minutes % MINUTES_PER_DAY == 0L ->
|
||||||
|
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
|
||||||
|
minutes % 60L == 0L ->
|
||||||
|
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
|
||||||
|
else ->
|
||||||
|
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
|
||||||
|
}
|
||||||
|
return stringResource(R.string.calendars_auto_backup_every, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
|
||||||
|
@Composable
|
||||||
|
private fun backupStatusText(status: BackupStatus): String {
|
||||||
|
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
||||||
|
val relative = DateUtils.getRelativeTimeSpanString(
|
||||||
|
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
|
||||||
|
).toString()
|
||||||
|
return if (status.lastSuccess) {
|
||||||
|
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.calendars_auto_backup_status_failed, relative)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
|
||||||
|
@Composable
|
||||||
|
private fun BackupIntervalDialog(
|
||||||
|
currentMinutes: Long,
|
||||||
|
onConfirm: (Long) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Pick the largest unit the current value divides into.
|
||||||
|
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
|
||||||
|
val units = stringArrayResource(R.array.backup_interval_units).toList()
|
||||||
|
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
|
||||||
|
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
|
||||||
|
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.calendars_auto_backup_interval_min),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
|
||||||
|
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
|
||||||
|
onDismiss()
|
||||||
|
}) { Text(stringResource(R.string.reminder_custom_set)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MINUTES_PER_DAY = 1_440L
|
||||||
|
private const val MINUTES_PER_WEEK = 10_080L
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.calendars
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one-time notice that Calendula now follows the device's per-calendar
|
||||||
|
* visibility (#75), armed by `CalendarVisibilityReconciler`. The app does not
|
||||||
|
* switch those calendars back on — that would un-hide them everywhere else too.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class CalendarVisibilityNoticeViewModel @Inject constructor(
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
|
private val reconciler: CalendarVisibilityReconciler,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile whenever the app comes up with the calendar permission held,
|
||||||
|
* rather than off one grant route: a permission granted on Android's
|
||||||
|
* app-settings screen never reaches the permission screen's callback.
|
||||||
|
*/
|
||||||
|
fun reconcile() {
|
||||||
|
viewModelScope.launch { reconciler.run() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val pending: StateFlow<Boolean> = prefs.visibilityNoticePending
|
||||||
|
.map { it == true }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun dismiss() {
|
||||||
|
viewModelScope.launch { prefs.setVisibilityNoticePending(false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain informational dialog — one acknowledgement, nothing to decide. */
|
||||||
|
@Composable
|
||||||
|
fun CalendarVisibilityNoticeDialog(onDismiss: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
icon = { Icon(Icons.Default.VisibilityOff, contentDescription = null) },
|
||||||
|
title = { Text(stringResource(R.string.calendars_visibility_notice_title)) },
|
||||||
|
text = { Text(stringResource(R.string.calendars_visibility_notice_message)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.dialog_ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,9 +4,6 @@ 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.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -26,25 +23,22 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
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.FileUpload
|
|
||||||
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
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -66,82 +60,68 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.listSaver
|
|
||||||
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.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.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
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.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.accountGroupTitle
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
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.positionOf
|
|
||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
|
||||||
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
|
||||||
|
|
||||||
// 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",
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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, and synced calendars
|
||||||
* and lists synced calendars read-only with a per-account "manage in the source
|
* read-only with a per-account "manage in the source app" deep-link.
|
||||||
* app" deep-link — the app never touches a synced calendar's server. A
|
*
|
||||||
* full-screen destination; [onBack] pops it.
|
* Export/import lives in its own Settings entry ([BackupScreen], #69); this
|
||||||
|
* screen only points at it. [onBack] pops the destination.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarsScreen(
|
fun CalendarsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onImport: (android.net.Uri) -> 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
|
||||||
@@ -157,6 +137,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) {
|
||||||
@@ -176,22 +157,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,
|
|
||||||
onImport = onImport,
|
|
||||||
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,28 +173,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, Set<Long>?) -> Unit,
|
|
||||||
onImport: (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)
|
||||||
|
|
||||||
@@ -232,47 +197,6 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 { onExportBackup(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(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,
|
||||||
@@ -280,12 +204,12 @@ private fun CalendarsList(
|
|||||||
predictiveBack = true,
|
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,
|
||||||
@@ -297,17 +221,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,
|
||||||
@@ -316,7 +240,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) },
|
||||||
@@ -325,82 +249,22 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backup — local calendars have no sync, so a .ics export is their only
|
// Pointer to the Backup entry (#69), so it stays findable from here.
|
||||||
// safety net. Offered only when there is something exportable: the user's
|
Spacer(Modifier.height(16.dp))
|
||||||
// own local calendars (managed special-dates mirrors don't count).
|
GroupedRow(
|
||||||
val exportable = local.filter { it.canModifyContents && !it.isManaged }
|
title = stringResource(R.string.settings_section_backup),
|
||||||
// Restore/import can target any writable, non-managed calendar (local or
|
summary = stringResource(R.string.settings_backup_subtitle),
|
||||||
// synced), so its availability is broader than export's.
|
position = Position.Alone,
|
||||||
val canImport = (local + synced).any { it.canModifyContents && !it.isManaged }
|
leading = { LeadingAvatar(Icons.Default.Backup) },
|
||||||
if (exportable.isNotEmpty()) {
|
trailing = {
|
||||||
Spacer(Modifier.height(16.dp))
|
Icon(
|
||||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
HintText(stringResource(R.string.calendars_backup_hint))
|
contentDescription = null,
|
||||||
// One connected card: the one-time export on top, then automatic
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
// 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 = onSetAutoBackupEnabled)
|
|
||||||
},
|
|
||||||
onClick = { onSetAutoBackupEnabled(!autoBackup.enabled) },
|
|
||||||
)
|
|
||||||
if (autoBackup.enabled) {
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_auto_backup_folder),
|
|
||||||
summary = rememberFolderName(autoBackup.folderUri)
|
|
||||||
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
|
||||||
position = Position.Middle,
|
|
||||||
onClick = { runCatching { pickFolder.launch(null) } },
|
|
||||||
)
|
)
|
||||||
GroupedRow(
|
},
|
||||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
onClick = onOpenBackup,
|
||||||
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.
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
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) } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
@@ -410,14 +274,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,
|
||||||
@@ -429,29 +298,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 ->
|
// Actionable calendars first; non-syncing ones at the
|
||||||
val disabled = calendar.id in disabledIds
|
// 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(
|
{
|
||||||
calendarName = calendar.displayName,
|
EnableSwitch(
|
||||||
enabled = !disabled,
|
calendarName = calendar.displayName,
|
||||||
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
enabled = calendar.isVisibleInSystem,
|
||||||
)
|
onToggle = { enabled ->
|
||||||
|
onSetVisible(calendar.id, enabled)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -459,93 +340,6 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showInterval) {
|
|
||||||
BackupIntervalDialog(
|
|
||||||
currentMinutes = autoBackup.intervalMinutes,
|
|
||||||
onConfirm = onSetAutoBackupInterval,
|
|
||||||
onDismiss = { showInterval = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showExportPicker) {
|
|
||||||
ExportCalendarPicker(
|
|
||||||
calendars = local.filter { it.canModifyContents && !it.isManaged },
|
|
||||||
onExport = onExportBackup,
|
|
||||||
onDismiss = { showExportPicker = 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: (android.net.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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -559,12 +353,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
|
||||||
@@ -591,11 +387,20 @@ private fun CalendarEditor(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (!isNew) {
|
if (!isNew) {
|
||||||
IconButton(onClick = { confirmDelete = true }) {
|
// Disabled rather than hidden while the special-dates
|
||||||
|
// sync owns this calendar; the card below says why.
|
||||||
|
IconButton(
|
||||||
|
onClick = { confirmDelete = true },
|
||||||
|
enabled = !deleteLocked,
|
||||||
|
) {
|
||||||
Icon(
|
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
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -624,7 +429,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 },
|
||||||
@@ -696,10 +514,28 @@ 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.
|
||||||
* touching any provider data. Carries its own content description so the toggle
|
*/
|
||||||
* is self-describing to screen readers even on a dimmed row.
|
@Composable
|
||||||
|
private fun calendarRowSummary(calendar: CalendarSource): String? {
|
||||||
|
val states = calendar.stateLabels().map { label ->
|
||||||
|
stringResource(
|
||||||
|
when (label) {
|
||||||
|
CalendarStateLabel.MANAGED -> R.string.calendars_state_managed
|
||||||
|
CalendarStateLabel.READ_ONLY -> R.string.calendars_state_read_only
|
||||||
|
CalendarStateLabel.NOT_SYNCED -> R.string.calendars_state_not_synced
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val parts = states + listOfNotNull(calendar.description?.takeIf { it.isNotBlank() })
|
||||||
|
return parts.joinToString(" · ").ifEmpty { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-row on/off control, writing the system's device-local
|
||||||
|
* `Calendars.VISIBLE`. Unchecked drops the calendar out of every surface and
|
||||||
|
* stops its reminders. Carries its own content description.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun EnableSwitch(
|
private fun EnableSwitch(
|
||||||
@@ -707,7 +543,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,
|
||||||
@@ -881,113 +717,31 @@ private fun CalendarGroupMenu(
|
|||||||
|
|
||||||
|
|
||||||
@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
|
||||||
|
|||||||
@@ -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,25 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = AutoBackupUiState(),
|
initialValue = AutoBackupUiState(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Managed special-dates calendars whose deletion would not stick: while the
|
||||||
|
* feature is on, `SpecialDatesSyncEngine.reconcileCalendars` undoes it on
|
||||||
|
* the next pass. Read off each calendar's durable [CalendarSource.isManaged]
|
||||||
|
* marker rather than the stored ids, which lag a sync pass behind.
|
||||||
|
*/
|
||||||
|
val deleteLockedCalendarIds: StateFlow<Set<Long>> = combine(
|
||||||
|
calendars,
|
||||||
|
settingsPrefs.specialDatesEnabled,
|
||||||
|
) { sources, enabled ->
|
||||||
|
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
|
||||||
|
}
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
private val _error = MutableStateFlow(false)
|
private val _error = MutableStateFlow(false)
|
||||||
val error: StateFlow<Boolean> = _error.asStateFlow()
|
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||||
|
|
||||||
@@ -140,54 +139,21 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable or disable a calendar app-side. Disabling removes it from every
|
* Switch a calendar on or off — the app's one visibility model, writing the
|
||||||
* surface but Settings → Calendars (and hides its events) without touching
|
* system's `Calendars.VISIBLE`. A reminder that came due while the calendar
|
||||||
* provider data — purely a reversible Calendula-local view choice.
|
* was off stays gone when it is switched back on: the watermark has already
|
||||||
|
* moved past it (#75).
|
||||||
*/
|
*/
|
||||||
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. Each row is written on its
|
||||||
* "toggle all" affordance on an account header. Done as one set update so the
|
* own, in one coroutine so the writes can't race.
|
||||||
* per-calendar [setDisabled] calls 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,80 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One account's calendars, as every surface that lists calendars by account
|
||||||
|
* shows them. An account is identified by name **and** type (#77): a Google and
|
||||||
|
* a DAVx5 account can share an address and still be two separate accounts.
|
||||||
|
*/
|
||||||
|
data class CalendarAccountGroup(
|
||||||
|
/** Stable identity: what makes two calendars belong to the same account. */
|
||||||
|
val key: AccountKey,
|
||||||
|
/** The account's own name, as shown when it is unambiguous. */
|
||||||
|
val label: String,
|
||||||
|
/** True when another group shows the same [label] under a different type. */
|
||||||
|
val ambiguous: Boolean,
|
||||||
|
val calendars: List<CalendarSource>,
|
||||||
|
) {
|
||||||
|
val accountType: String get() = key.type
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pair a group is keyed on. */
|
||||||
|
data class AccountKey(val name: String, val type: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group [calendars] under their owning account, preserving the provider's order
|
||||||
|
* within each group and ordering groups by first appearance.
|
||||||
|
*
|
||||||
|
* The label falls back through name → type → the first calendar's own name, so
|
||||||
|
* a calendar with no account still lands somewhere sensible.
|
||||||
|
*/
|
||||||
|
fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
|
||||||
|
val grouped = groupBy { AccountKey(it.accountName, it.accountType) }
|
||||||
|
val labels = grouped.mapValues { (key, cals) ->
|
||||||
|
key.name.ifBlank { key.type }.ifBlank { cals.first().displayName }
|
||||||
|
}
|
||||||
|
val shared = labels.values.groupingBy { it }.eachCount()
|
||||||
|
return grouped.map { (key, cals) ->
|
||||||
|
val label = labels.getValue(key)
|
||||||
|
CalendarAccountGroup(
|
||||||
|
key = key,
|
||||||
|
label = label,
|
||||||
|
ambiguous = shared.getValue(label) > 1,
|
||||||
|
calendars = cals,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to write above a group: its account name, qualified with the app the
|
||||||
|
* account comes from when another account shares the name (#77).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun accountGroupTitle(group: CalendarAccountGroup): String =
|
||||||
|
if (!group.ambiguous) {
|
||||||
|
group.label
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The human name of the app backing [accountType], falling back to the raw
|
||||||
|
* account type when no installed app resolves for it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun sourceAppName(accountType: String): String {
|
||||||
|
val context = LocalContext.current
|
||||||
|
return remember(accountType) {
|
||||||
|
val pm = context.packageManager
|
||||||
|
val packages = sourceAppPackages(context, accountType)
|
||||||
|
packages.firstNotNullOfOrNull { pkg ->
|
||||||
|
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
|
||||||
|
} ?: accountType
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,16 +14,17 @@ 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.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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)
|
||||||
@@ -34,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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.filled.Cloud
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -33,6 +34,7 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
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
|
* The app's single "which calendar" selection list, shared by the event editor
|
||||||
@@ -42,18 +44,21 @@ import de.jeanlucmakiola.floret.components.Position
|
|||||||
* account — with the calendars beneath it as a connected card, a colour chip on
|
* 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]
|
* each and a check on the selected one. Emits into the caller's [ColumnScope]
|
||||||
* (a scrolling column), so the caller owns the surrounding chrome.
|
* (a scrolling column), so the caller owns the surrounding chrome.
|
||||||
|
*
|
||||||
|
* The list holds event *targets* only, so a switched-off, read-only or managed
|
||||||
|
* calendar is absent (#76). [onManageCalendars], when given, adds the footer row
|
||||||
|
* naming the possible reasons and opening the calendar manager.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ColumnScope.CalendarPickerGroups(
|
fun ColumnScope.CalendarPickerGroups(
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
selectedId: Long?,
|
selectedId: Long?,
|
||||||
onSelect: (Long) -> Unit,
|
onSelect: (Long) -> Unit,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
||||||
val syncedGroups = remember(calendars) {
|
val syncedGroups = remember(calendars) {
|
||||||
calendars.filterNot { it.isLocal }
|
calendars.filterNot { it.isLocal }.groupByAccount()
|
||||||
.groupBy { it.accountName.ifBlank { it.accountType }.ifBlank { it.displayName } }
|
|
||||||
.toList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (local.isNotEmpty()) {
|
if (local.isNotEmpty()) {
|
||||||
@@ -65,16 +70,33 @@ fun ColumnScope.CalendarPickerGroups(
|
|||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
syncedGroups.forEachIndexed { index, (account, cals) ->
|
syncedGroups.forEachIndexed { index, group ->
|
||||||
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
||||||
CalendarPickerGroup(
|
CalendarPickerGroup(
|
||||||
title = account,
|
title = accountGroupTitle(group),
|
||||||
leading = { SourceLogo(cals.first().accountType) },
|
leading = { SourceLogo(group.accountType) },
|
||||||
calendars = cals,
|
calendars = group.calendars,
|
||||||
selectedId = selectedId,
|
selectedId = selectedId,
|
||||||
onSelect = onSelect,
|
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. */
|
/** One account's category header (avatar + name) atop its selectable calendars. */
|
||||||
@@ -99,13 +121,7 @@ private fun CalendarPickerGroup(
|
|||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
trailing = if (isSelected) {
|
trailing = if (isSelected) {
|
||||||
{
|
{ SelectedCheck() }
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
@@ -161,20 +177,22 @@ fun LeadingAvatar(icon: ImageVector) {
|
|||||||
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
|
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
|
||||||
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
|
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
|
||||||
val pm = context.packageManager
|
val pm = context.packageManager
|
||||||
val candidates = buildList {
|
for (pkg in sourceAppPackages(context, accountType)) {
|
||||||
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()
|
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
|
||||||
if (bitmap != null) return bitmap.asImageBitmap()
|
if (bitmap != null) return bitmap.asImageBitmap()
|
||||||
}
|
}
|
||||||
return null
|
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. */
|
/** Preferred app for account types whose authenticator isn't the app to open. */
|
||||||
internal fun curatedSourcePackage(accountType: String): String? = when {
|
internal fun curatedSourcePackage(accountType: String): String? = when {
|
||||||
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
||||||
|
|||||||
@@ -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,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.animation.ContentTransform
|
import androidx.compose.animation.ContentTransform
|
||||||
|
import androidx.compose.animation.SizeTransform
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@@ -23,9 +24,13 @@ import androidx.compose.ui.unit.IntOffset
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
@@ -33,27 +38,34 @@ import androidx.compose.ui.unit.IntOffset
|
|||||||
@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,
|
||||||
@@ -65,6 +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),
|
||||||
|
// AnimatedContent clips to the animating container by default, which
|
||||||
|
// shears the pages against the viewport edge as they pass. There is no
|
||||||
|
// size change here to contain — both pages are the same grid.
|
||||||
|
sizeTransform = SizeTransform(clip = false),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far a page travels, as a fraction of the container width.
|
||||||
|
*
|
||||||
|
* A full width was the obvious reading of "paging", but the two pages are
|
||||||
|
* stacked and both opaque, so a full-width slide showed one grid racing across
|
||||||
|
* another — the movement carried the whole transition and had a long way to go.
|
||||||
|
* Under M3's shared-axis pattern the offset only has to *hint* the direction
|
||||||
|
* while the cross-fade does the swapping, so a fifth of the width is plenty and
|
||||||
|
* leaves nothing skating past.
|
||||||
|
*/
|
||||||
|
private const val SLIDE_TRAVEL_DIVISOR = 5
|
||||||
|
|||||||
@@ -18,15 +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
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -37,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)
|
||||||
@@ -60,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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
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.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.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -24,15 +23,26 @@ import androidx.compose.ui.unit.dp
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
|
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
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||||
@@ -48,6 +58,10 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
* 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, for the all-day
|
||||||
|
* pickers: the hour comes from the separate "show all-day reminders at" setting,
|
||||||
|
* so the lead time alone doesn't say when anything happens.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ReminderDefaultPicker(
|
fun ReminderDefaultPicker(
|
||||||
@@ -57,6 +71,7 @@ fun ReminderDefaultPicker(
|
|||||||
allowInherit: Boolean,
|
allowInherit: Boolean,
|
||||||
onSelect: (ReminderOverride) -> 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
|
||||||
@@ -132,6 +147,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) }) },
|
||||||
@@ -196,37 +212,35 @@ private fun CustomReminderEditor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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]) — only
|
||||||
|
* the concrete span tells "This week" and "Next 7 days" 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,
|
||||||
) {
|
) {
|
||||||
@@ -240,10 +254,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 agenda's own end-day arithmetic (anchor + dayCount - 1), so the dates
|
||||||
|
// match what it will show.
|
||||||
|
val windowSummary: (AgendaRange) -> String = { range ->
|
||||||
|
val end = today.plus(range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY)
|
||||||
|
agendaRangeWindowSummary(range, today, end, locale, monthAsSpan = true)
|
||||||
|
}
|
||||||
|
|
||||||
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
val 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) {
|
||||||
@@ -278,6 +303,9 @@ fun AgendaRangePicker(
|
|||||||
} else {
|
} else {
|
||||||
stringResource(R.string.agenda_range_custom)
|
stringResource(R.string.agenda_range_custom)
|
||||||
},
|
},
|
||||||
|
// An unset custom window has no day count yet, so the row stays
|
||||||
|
// single-line until it does.
|
||||||
|
summary = if (customSelected) windowSummary(selected) else null,
|
||||||
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
|
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
|
||||||
selected = customSelected,
|
selected = customSelected,
|
||||||
trailing = if (customSelected) {
|
trailing = if (customSelected) {
|
||||||
|
|||||||
@@ -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,33 @@ fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule's first few dates as one line — "Next: 30 Jul, 6 Aug, 13 Aug" — for
|
||||||
|
* the recurrence picker, where [recurrenceText]'s phrase says what the rule is
|
||||||
|
* and this says what it does. Expands via [upcomingOccurrences] and inherits its
|
||||||
|
* limits. A rule that yields nothing says so rather than showing an empty list.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun nextOccurrencesText(
|
||||||
|
rule: SimpleRecurrence,
|
||||||
|
start: kotlinx.datetime.LocalDate,
|
||||||
|
locale: Locale,
|
||||||
|
): String {
|
||||||
|
val dates = rule.upcomingOccurrences(start, limit = NEXT_OCCURRENCE_COUNT)
|
||||||
|
if (dates.isEmpty()) return stringResource(R.string.event_edit_recurrence_next_none)
|
||||||
|
// Years only once they carry information (see [spansMultipleYears]).
|
||||||
|
val pattern = if (occurrencesSpanYears(dates, start)) "dMMMy" else "dMMM"
|
||||||
|
val formatter = localizedDateFormatter(locale, pattern)
|
||||||
|
val formatted = dates.map { formatter.format(it.toJavaLocalDate()) }
|
||||||
|
return stringResource(
|
||||||
|
R.string.event_edit_recurrence_next,
|
||||||
|
ListFormatter.getInstance(locale).format(formatted),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enough dates to show a rhythm (and a skipped month), few enough for one line. */
|
||||||
|
private const val NEXT_OCCURRENCE_COUNT = 3
|
||||||
|
|
||||||
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
/** 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 +147,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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.rememberCalendarPageSwipe
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.components.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.domain.TimeZoneOption
|
||||||
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||||
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
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.icuTimeZoneRegion
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.floret.components.OptionCard
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
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.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.floret.components.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,6 +141,31 @@ 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
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ import androidx.compose.ui.graphics.isSpecified
|
|||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
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.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
@@ -98,7 +100,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
@@ -111,6 +112,9 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||||
|
import de.jeanlucmakiola.calendula.domain.timesIn
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||||
@@ -126,27 +130,34 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.OptionCard
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
import de.jeanlucmakiola.floret.components.groupedShape as floretGroupedShape
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
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.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.nextOccurrencesText
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -156,13 +167,14 @@ import kotlinx.datetime.TimeZone
|
|||||||
import kotlinx.datetime.isoDayNumber
|
import kotlinx.datetime.isoDayNumber
|
||||||
import kotlinx.datetime.toJavaDayOfWeek
|
import kotlinx.datetime.toJavaDayOfWeek
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toKotlinDayOfWeek
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toJavaLocalTime
|
import kotlinx.datetime.toJavaLocalTime
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
|
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.time.format.TextStyle as JavaTextStyle
|
||||||
import java.time.temporal.WeekFields
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
@@ -184,6 +196,7 @@ fun EventEditScreen(
|
|||||||
initialStartMinutes: Int? = null,
|
initialStartMinutes: Int? = null,
|
||||||
initialForm: EventForm? = null,
|
initialForm: EventForm? = null,
|
||||||
initialFormSource: ImportSource = ImportSource.File,
|
initialFormSource: ImportSource = ImportSource.File,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
viewModel: EventEditViewModel = hiltViewModel(),
|
viewModel: EventEditViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||||
@@ -300,6 +313,7 @@ fun EventEditScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding),
|
.padding(innerPadding),
|
||||||
|
onManageCalendars = onManageCalendars,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -491,20 +505,27 @@ private fun EventEditContent(
|
|||||||
state: EventEditUiState,
|
state: EventEditUiState,
|
||||||
viewModel: EventEditViewModel,
|
viewModel: EventEditViewModel,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val form = state.form
|
val form = state.form
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
|
val firstDayOfWeek by viewModel.firstDayOfWeek.collectAsStateWithLifecycle()
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
// The title, date and recurrence are managed by the special-dates sync, so
|
// The title, date and recurrence are managed by the special-dates sync, so
|
||||||
// they're locked here; everything else (reminders, location, notes) is the
|
// they're locked here; everything else (reminders, location, notes) is the
|
||||||
// user's to edit.
|
// user's to edit.
|
||||||
val locked = state.isManaged
|
val locked = state.isManaged
|
||||||
|
// Read in the form's own window, not the picker's: the focused field lives
|
||||||
|
// here, so this is the controller that can put its keyboard away.
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
||||||
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
|
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
|
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showColorPicker by rememberSaveable { mutableStateOf(false) }
|
var showColorPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showTimezonePicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
// Pick a guest from contacts: a one-shot system picker returning the chosen
|
// Pick a guest from contacts: a one-shot system picker returning the chosen
|
||||||
@@ -541,7 +562,8 @@ private fun EventEditContent(
|
|||||||
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
|
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
|
||||||
// The accent ties the form to the detail screen's design language: the
|
// The accent ties the form to the detail screen's design language: the
|
||||||
// bar under the title takes the target calendar's colour.
|
// bar under the title takes the target calendar's colour.
|
||||||
val accent = selectedCalendar?.let { pastelize(it.color, dark) }
|
val soften = LocalSoftenColors.current
|
||||||
|
val accent = selectedCalendar?.let { eventFill(it.color, dark, soften) }
|
||||||
?: MaterialTheme.colorScheme.primary
|
?: MaterialTheme.colorScheme.primary
|
||||||
val gap = 12.dp
|
val gap = 12.dp
|
||||||
|
|
||||||
@@ -668,6 +690,28 @@ private fun EventEditContent(
|
|||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// The rows above edit a pinned event in *its own* zone, which is the
|
||||||
|
// time it was set at — but that says nothing about when it lands for
|
||||||
|
// whoever is reading it. Spell the local equivalent out rather than
|
||||||
|
// leaving the user to do the offset arithmetic. Absent (null) unless
|
||||||
|
// the event is pinned somewhere other than here.
|
||||||
|
// Keyed rather than recomputed: this sits in the same Column as the
|
||||||
|
// title field, so it would otherwise re-parse the zone on every
|
||||||
|
// keystroke.
|
||||||
|
val localTimes = remember(form.timezone, form.start, form.end, form.isAllDay) {
|
||||||
|
form.timesIn(TimeZone.currentSystemDefault())
|
||||||
|
}
|
||||||
|
localTimes?.let { (localStart, localEnd) ->
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.event_edit_timezone_local_time,
|
||||||
|
formatTimeRange(localStart, localEnd, locale),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
@@ -704,6 +748,54 @@ private fun EventEditContent(
|
|||||||
|
|
||||||
// Optional sections: which ones render by default is a setting; the
|
// Optional sections: which ones render by default is a setting; the
|
||||||
// rest unfold behind the "more fields" button below.
|
// rest unfold behind the "more fields" button below.
|
||||||
|
OptionalFormSection(visible = EventFormField.Timezone in state.visibleFields) {
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
EditCard(
|
||||||
|
icon = Icons.Default.Public,
|
||||||
|
iconContentDescription = null,
|
||||||
|
onClick = { showTimezonePicker = true },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
// Resolved at the event's own start, not at "now": the
|
||||||
|
// abbreviation and offset shown ("CEST · GMT+02:00") must be
|
||||||
|
// the ones that apply when the event actually happens.
|
||||||
|
val pinned = remember(form.timezone, form.start, locale) {
|
||||||
|
form.timezone
|
||||||
|
?.let { id -> runCatching { id to TimeZone.of(id) }.getOrNull() }
|
||||||
|
?.let { (id, zone) ->
|
||||||
|
timeZoneOptionOf(
|
||||||
|
id,
|
||||||
|
locale,
|
||||||
|
at = form.start.toInstant(zone).toJavaInstant(),
|
||||||
|
regionOf = ::icuTimeZoneRegion,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = pinned?.label
|
||||||
|
?: stringResource(R.string.event_edit_timezone_device),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = pinned?.let { zoneDescriptor(it) }
|
||||||
|
?: stringResource(R.string.event_edit_timezone_device_summary),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OptionalFormSection(visible = EventFormField.Location in state.visibleFields) {
|
OptionalFormSection(visible = EventFormField.Location in state.visibleFields) {
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
EditCard(
|
EditCard(
|
||||||
@@ -893,7 +985,7 @@ private fun EventEditContent(
|
|||||||
icon = Icons.Default.Palette,
|
icon = Icons.Default.Palette,
|
||||||
iconContentDescription = stringResource(R.string.event_edit_color),
|
iconContentDescription = stringResource(R.string.event_edit_color),
|
||||||
iconTint = if (colorSupported && swatch != null) {
|
iconTint = if (colorSupported && swatch != null) {
|
||||||
pastelize(swatch, dark)
|
eventFill(swatch, dark, soften)
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
},
|
},
|
||||||
@@ -1032,6 +1124,15 @@ private fun EventEditContent(
|
|||||||
null -> Unit
|
null -> Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A full-screen picker is a change of place, so the keyboard shouldn't
|
||||||
|
// follow it. The field keeps its text; only focus and the IME go.
|
||||||
|
LaunchedEffect(showCalendarPicker) {
|
||||||
|
if (showCalendarPicker) {
|
||||||
|
focusManager.clearFocus(force = true)
|
||||||
|
keyboardController?.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showCalendarPicker) {
|
if (showCalendarPicker) {
|
||||||
CalendarPicker(
|
CalendarPicker(
|
||||||
calendars = state.calendars,
|
calendars = state.calendars,
|
||||||
@@ -1040,6 +1141,15 @@ private fun EventEditContent(
|
|||||||
viewModel.setCalendar(it)
|
viewModel.setCalendar(it)
|
||||||
showCalendarPicker = false
|
showCalendarPicker = false
|
||||||
},
|
},
|
||||||
|
// Close the picker first: it is a Compose Dialog in its own window,
|
||||||
|
// always above the activity's content, so the manager would
|
||||||
|
// otherwise open behind it and the tap would look dead.
|
||||||
|
onManageCalendars = onManageCalendars?.let { openManager ->
|
||||||
|
{
|
||||||
|
showCalendarPicker = false
|
||||||
|
openManager()
|
||||||
|
}
|
||||||
|
},
|
||||||
onDismiss = { showCalendarPicker = false },
|
onDismiss = { showCalendarPicker = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1059,7 +1169,8 @@ private fun EventEditContent(
|
|||||||
if (showRecurrencePicker) {
|
if (showRecurrencePicker) {
|
||||||
RecurrencePickerDialog(
|
RecurrencePickerDialog(
|
||||||
current = form.rrule,
|
current = form.rrule,
|
||||||
startDay = form.start.date.dayOfWeek,
|
startDate = form.start.date,
|
||||||
|
firstDayOfWeek = firstDayOfWeek,
|
||||||
onSelect = { rrule ->
|
onSelect = { rrule ->
|
||||||
viewModel.setRecurrence(rrule)
|
viewModel.setRecurrence(rrule)
|
||||||
showRecurrencePicker = false
|
showRecurrencePicker = false
|
||||||
@@ -1079,6 +1190,16 @@ private fun EventEditContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showTimezonePicker) {
|
||||||
|
TimeZonePickerDialog(
|
||||||
|
selected = form.timezone,
|
||||||
|
deviceZoneId = ZoneId.systemDefault().id,
|
||||||
|
recents = state.recentTimeZones,
|
||||||
|
onSelect = viewModel::setTimezone,
|
||||||
|
onDismiss = { showTimezonePicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showColorPicker) {
|
if (showColorPicker) {
|
||||||
ColorPickerDialog(
|
ColorPickerDialog(
|
||||||
palette = state.colorPalette,
|
palette = state.colorPalette,
|
||||||
@@ -1216,14 +1337,20 @@ private enum class RecurrenceEndMode { Never, Until, Count }
|
|||||||
* and an end condition — only that step needs an OK button. A rule the
|
* and an end condition — only that step needs an OK button. A rule the
|
||||||
* simple shape can't express (ordinal BYDAY etc.) stays untouched unless the
|
* simple shape can't express (ordinal BYDAY etc.) stays untouched unless the
|
||||||
* user picks something here.
|
* user picks something here.
|
||||||
|
*
|
||||||
|
* Both steps show the rule as *dates* as well as words: every preset row
|
||||||
|
* carries the next few occurrences it would produce from [startDate], and the
|
||||||
|
* custom step repeats that under its live read-out.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun RecurrencePickerDialog(
|
private fun RecurrencePickerDialog(
|
||||||
current: String?,
|
current: String?,
|
||||||
startDay: DayOfWeek,
|
startDate: LocalDate,
|
||||||
|
firstDayOfWeek: DayOfWeek,
|
||||||
onSelect: (String?) -> Unit,
|
onSelect: (String?) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val startDay = startDate.dayOfWeek
|
||||||
val parsed = remember(current) { current?.let(::parseSimpleRecurrence) }
|
val parsed = remember(current) { current?.let(::parseSimpleRecurrence) }
|
||||||
val isPlainPreset = parsed != null && parsed.interval == 1 &&
|
val isPlainPreset = parsed != null && parsed.interval == 1 &&
|
||||||
parsed.end == RecurrenceEnd.Never && parsed.byDays.isEmpty()
|
parsed.end == RecurrenceEnd.Never && parsed.byDays.isEmpty()
|
||||||
@@ -1255,23 +1382,39 @@ private fun RecurrencePickerDialog(
|
|||||||
|
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val untilDate = untilIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
val untilDate = untilIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||||
val interval = intervalText.toIntOrNull()?.takeIf { it in 1..999 }
|
// remember() must not sit behind the ?. — a slot that appears and disappears
|
||||||
val count = countText.toIntOrNull()?.takeIf { it in 1..999 }
|
// as a date is picked breaks Compose's positional memoisation. Format
|
||||||
|
// unconditionally and let the null fall through to a null summary.
|
||||||
|
val untilSummary = remember(untilDate, locale) {
|
||||||
|
untilDate?.let {
|
||||||
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||||
|
.withLocale(locale).format(it.toJavaLocalDate())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A blank amount field shows its placeholder ("1" / "10") greyed out, so read
|
||||||
|
// blank as that default rather than as an error — otherwise simply clearing
|
||||||
|
// the field silently greys out OK. Only a real out-of-range value (0) is
|
||||||
|
// invalid, and that's the one case the read-out below reports.
|
||||||
|
val interval = if (intervalText.isBlank()) 1 else intervalText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
|
val count = if (countText.isBlank()) 10 else countText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
val customEnd: RecurrenceEnd? = when (endMode) {
|
val customEnd: RecurrenceEnd? = when (endMode) {
|
||||||
RecurrenceEndMode.Never -> RecurrenceEnd.Never
|
RecurrenceEndMode.Never -> RecurrenceEnd.Never
|
||||||
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
|
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
|
||||||
RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) }
|
RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) }
|
||||||
}
|
}
|
||||||
val customResult: String? = if (interval != null && customEnd != null) {
|
// Kept as the rule object, not just its RRULE text: the read-out and the
|
||||||
|
// date list must describe the one thing OK would save.
|
||||||
|
val customRule: SimpleRecurrence? = if (interval != null && customEnd != null) {
|
||||||
SimpleRecurrence(
|
SimpleRecurrence(
|
||||||
freq = freq,
|
freq = freq,
|
||||||
interval = interval,
|
interval = interval,
|
||||||
end = customEnd,
|
end = customEnd,
|
||||||
byDays = if (freq == RecurrenceFreq.Weekly) daysMask.toDaySet() else emptySet(),
|
byDays = if (freq == RecurrenceFreq.Weekly) daysMask.toDaySet() else emptySet(),
|
||||||
).toRRule()
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
val customResult: String? = customRule?.toRRule()
|
||||||
|
|
||||||
FullScreenPicker(
|
FullScreenPicker(
|
||||||
title = stringResource(R.string.event_detail_recurrence),
|
title = stringResource(R.string.event_detail_recurrence),
|
||||||
@@ -1292,13 +1435,24 @@ private fun RecurrencePickerDialog(
|
|||||||
title = stringResource(R.string.event_edit_recurrence_none),
|
title = stringResource(R.string.event_edit_recurrence_none),
|
||||||
position = positionOf(0, rowCount),
|
position = positionOf(0, rowCount),
|
||||||
selected = current == null,
|
selected = current == null,
|
||||||
|
trailing = if (current == null) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = { onSelect(null) },
|
onClick = { onSelect(null) },
|
||||||
)
|
)
|
||||||
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(recurrencePresetLabel(entry)),
|
title = stringResource(recurrencePresetLabel(entry)),
|
||||||
|
summary = nextOccurrencesText(SimpleRecurrence(entry), startDate, locale),
|
||||||
position = positionOf(index + 1, rowCount),
|
position = positionOf(index + 1, rowCount),
|
||||||
selected = isPlainPreset && parsed?.freq == entry,
|
selected = isPlainPreset && parsed?.freq == entry,
|
||||||
|
trailing = if (isPlainPreset && parsed?.freq == entry) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
|
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1306,88 +1460,206 @@ private fun RecurrencePickerDialog(
|
|||||||
title = stringResource(R.string.event_edit_recurrence_custom),
|
title = stringResource(R.string.event_edit_recurrence_custom),
|
||||||
position = positionOf(rowCount - 1, rowCount),
|
position = positionOf(rowCount - 1, rowCount),
|
||||||
selected = current != null && !isPlainPreset,
|
selected = current != null && !isPlainPreset,
|
||||||
|
trailing = if (current != null && !isPlainPreset) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = { customMode = true },
|
onClick = { customMode = true },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Column(
|
// Section gaps live on each block (not a parent spacedBy) so the
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
// weekday card's gap collapses *with* it under AnimatedVisibility —
|
||||||
modifier = Modifier.padding(horizontal = 24.dp),
|
// a parent arrangement would leave a residual gap that snaps shut.
|
||||||
) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
// A live, human-readable read-out of the rule being built, so the
|
||||||
Text(
|
// effect of the controls below is never a guess. It renders
|
||||||
text = stringResource(R.string.event_edit_recurrence_every),
|
// *[customResult] itself* — the exact string OK would save — so it
|
||||||
style = MaterialTheme.typography.titleMedium,
|
// can never describe a different rule than the one that lands. When
|
||||||
)
|
// the form can't produce a rule, it says why instead of falling
|
||||||
Spacer(Modifier.width(12.dp))
|
// back to defaults and quietly disagreeing with the controls.
|
||||||
DialogAmountField(
|
// minLines reserves the second line up front: without it the whole
|
||||||
value = intervalText,
|
// stack below shifts a line as the phrase grows with each weekday.
|
||||||
onValueChange = { intervalText = it },
|
|
||||||
placeholder = "1",
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
DialogUnitDropdown(
|
|
||||||
label = stringResource(recurrenceUnitLabel(freq)),
|
|
||||||
entries = RecurrenceFreq.entries.map {
|
|
||||||
stringResource(recurrenceUnitLabel(it))
|
|
||||||
},
|
|
||||||
onPick = { freq = RecurrenceFreq.entries[it] },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (freq == RecurrenceFreq.Weekly) {
|
|
||||||
WeekdayToggleRow(
|
|
||||||
selected = daysMask.toDaySet(),
|
|
||||||
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
|
|
||||||
locale = locale,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.event_edit_recurrence_ends),
|
text = customResult?.let { recurrenceText(it, locale) }
|
||||||
|
?: AnnotatedString(
|
||||||
|
stringResource(R.string.event_edit_recurrence_incomplete),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = if (customResult != null) {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
},
|
||||||
|
minLines = 2,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rendered even while incomplete (as an empty line), so the
|
||||||
|
// controls below don't shift as it comes and goes.
|
||||||
|
Text(
|
||||||
|
text = customRule?.let { nextOccurrencesText(it, startDate, locale) }.orEmpty(),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
GroupedRow(
|
// How often: an interval amount plus a frequency segmented row —
|
||||||
title = stringResource(R.string.event_edit_recurrence_end_never),
|
// all four units on show, no unit hidden behind a dropdown.
|
||||||
position = positionOf(0, 3),
|
GroupedSurface(
|
||||||
selected = endMode == RecurrenceEndMode.Never,
|
position = Position.Alone,
|
||||||
onClick = { endMode = RecurrenceEndMode.Never },
|
modifier = Modifier
|
||||||
)
|
.padding(top = 16.dp)
|
||||||
GroupedRow(
|
.padding(horizontal = 16.dp),
|
||||||
title = stringResource(R.string.event_edit_recurrence_end_until),
|
|
||||||
summary = untilDate?.let {
|
|
||||||
remember(it, locale) {
|
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
|
||||||
.withLocale(locale).format(it.toJavaLocalDate())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
position = positionOf(1, 3),
|
|
||||||
selected = endMode == RecurrenceEndMode.Until,
|
|
||||||
onClick = {
|
|
||||||
endMode = RecurrenceEndMode.Until
|
|
||||||
showUntilPicker = true
|
|
||||||
},
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.event_edit_recurrence_end_count),
|
|
||||||
position = positionOf(2, 3),
|
|
||||||
selected = endMode == RecurrenceEndMode.Count,
|
|
||||||
onClick = { endMode = RecurrenceEndMode.Count },
|
|
||||||
)
|
|
||||||
if (endMode == RecurrenceEndMode.Count) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
|
|
||||||
) {
|
) {
|
||||||
DialogAmountField(
|
Column(
|
||||||
value = countText,
|
modifier = Modifier.padding(16.dp),
|
||||||
onValueChange = { countText = it },
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
placeholder = "10",
|
) {
|
||||||
)
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Spacer(Modifier.width(12.dp))
|
Text(
|
||||||
|
text = stringResource(R.string.event_edit_recurrence_every),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
DialogAmountField(
|
||||||
|
value = intervalText,
|
||||||
|
onValueChange = { intervalText = it },
|
||||||
|
placeholder = "1",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = freq == entry,
|
||||||
|
onClick = { freq = entry },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(
|
||||||
|
index, RecurrenceFreq.entries.size,
|
||||||
|
),
|
||||||
|
// Drop the default check-icon slot: four segments
|
||||||
|
// share one row, and its reserved width is what
|
||||||
|
// pushes longer labels ("Monate", "Wochen") into
|
||||||
|
// an ellipsis on a compact screen. Selection is
|
||||||
|
// already carried by the segment's fill.
|
||||||
|
icon = {},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(recurrenceUnitLabel(entry)),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekday picks — only meaningful on a weekly rule. The 16dp gap
|
||||||
|
// sits inside the animated block so it grows/shrinks with the card.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = freq == RecurrenceFreq.Weekly,
|
||||||
|
enter = expandEnter(),
|
||||||
|
exit = collapseExit(),
|
||||||
|
) {
|
||||||
|
GroupedSurface(
|
||||||
|
position = Position.Alone,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 16.dp)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
WeekdayToggleRow(
|
||||||
|
selected = daysMask.toDaySet(),
|
||||||
|
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
|
||||||
|
locale = locale,
|
||||||
|
firstDay = firstDayOfWeek,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ends: a connected never / on-a-date / after-N group, the count
|
||||||
|
// field folding into the bottom of the run when chosen.
|
||||||
|
Column(modifier = Modifier.padding(top = 16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.event_edit_recurrence_times),
|
text = stringResource(R.string.event_edit_recurrence_ends),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp),
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.event_edit_recurrence_end_never),
|
||||||
|
position = Position.Top,
|
||||||
|
selected = endMode == RecurrenceEndMode.Never,
|
||||||
|
trailing = if (endMode == RecurrenceEndMode.Never) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { endMode = RecurrenceEndMode.Never },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.event_edit_recurrence_end_until),
|
||||||
|
summary = untilSummary,
|
||||||
|
position = Position.Middle,
|
||||||
|
selected = endMode == RecurrenceEndMode.Until,
|
||||||
|
trailing = if (endMode == RecurrenceEndMode.Until) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
endMode = RecurrenceEndMode.Until
|
||||||
|
showUntilPicker = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.event_edit_recurrence_end_count),
|
||||||
|
position = if (endMode == RecurrenceEndMode.Count) {
|
||||||
|
Position.Middle
|
||||||
|
} else {
|
||||||
|
Position.Bottom
|
||||||
|
},
|
||||||
|
selected = endMode == RecurrenceEndMode.Count,
|
||||||
|
trailing = if (endMode == RecurrenceEndMode.Count) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { endMode = RecurrenceEndMode.Count },
|
||||||
|
)
|
||||||
|
// Animated for the same reason the weekday card is: this is the
|
||||||
|
// picker's other collapsible block, and a bare `if` here made
|
||||||
|
// the ends group snap while the card above it glided.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = endMode == RecurrenceEndMode.Count,
|
||||||
|
enter = expandEnter(),
|
||||||
|
exit = collapseExit(),
|
||||||
|
) {
|
||||||
|
GroupedSurface(
|
||||||
|
position = Position.Bottom,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
) {
|
||||||
|
DialogAmountField(
|
||||||
|
value = countText,
|
||||||
|
onValueChange = { countText = it },
|
||||||
|
placeholder = "10",
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_edit_recurrence_times),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1402,7 +1674,13 @@ private fun RecurrencePickerDialog(
|
|||||||
untilIso = it.toString()
|
untilIso = it.toString()
|
||||||
showUntilPicker = false
|
showUntilPicker = false
|
||||||
},
|
},
|
||||||
onDismiss = { showUntilPicker = false },
|
onDismiss = {
|
||||||
|
showUntilPicker = false
|
||||||
|
// Backing out of the date picker with nothing chosen would leave
|
||||||
|
// "on date" selected but dateless — a state that can't be saved and
|
||||||
|
// greys out OK with no visible cause. Fall back to "never".
|
||||||
|
if (untilIso == null) endMode = RecurrenceEndMode.Never
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1417,14 +1695,15 @@ private fun WeekdayToggleRow(
|
|||||||
selected: Set<DayOfWeek>,
|
selected: Set<DayOfWeek>,
|
||||||
onToggle: (DayOfWeek) -> Unit,
|
onToggle: (DayOfWeek) -> Unit,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
firstDay: DayOfWeek,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val days = remember(locale) {
|
val days = remember(firstDay) {
|
||||||
val first = WeekFields.of(locale).firstDayOfWeek.toKotlinDayOfWeek()
|
(0 until 7).map { DayOfWeek(((firstDay.isoDayNumber - 1 + it) % 7) + 1) }
|
||||||
(0 until 7).map { DayOfWeek(((first.isoDayNumber - 1 + it) % 7) + 1) }
|
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
days.forEach { day ->
|
days.forEach { day ->
|
||||||
val isSelected = day in selected
|
val isSelected = day in selected
|
||||||
@@ -1477,18 +1756,14 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) {
|
|||||||
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
|
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Corner shape for a card at [position] in a grouped run (mirrors GroupedRow). */
|
/**
|
||||||
private fun groupedShape(position: Position, full: Dp = 20.dp, small: Dp = 6.dp): Shape =
|
* Corner shape for a card at [position] in a grouped run. Delegates to the
|
||||||
when (position) {
|
* family primitive with the same radii [GroupedSurface] draws at rest — a local
|
||||||
Position.Alone -> RoundedCornerShape(full)
|
* copy had drifted to 20dp, so a card sharing a run with GroupedRows had
|
||||||
Position.Top -> RoundedCornerShape(
|
* visibly tighter outer corners than the rows above it.
|
||||||
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
|
*/
|
||||||
)
|
private fun groupedShape(position: Position): Shape =
|
||||||
Position.Middle -> RoundedCornerShape(small)
|
floretGroupedShape(position, full = 22.dp, small = 6.dp)
|
||||||
Position.Bottom -> RoundedCornerShape(
|
|
||||||
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The 2dp gap that visually separates grouped cards (none after the last). */
|
/** The 2dp gap that visually separates grouped cards (none after the last). */
|
||||||
private fun groupedGap(position: Position): Modifier = when (position) {
|
private fun groupedGap(position: Position): Modifier = when (position) {
|
||||||
@@ -1749,6 +2024,9 @@ private fun readContactAddress(context: Context, uri: Uri): String? =
|
|||||||
/**
|
/**
|
||||||
* Visibility selector: one card per level, each with its own icon; the
|
* Visibility selector: one card per level, each with its own icon; the
|
||||||
* current level is highlighted. Tap picks and closes.
|
* current level is highlighted. Tap picks and closes.
|
||||||
|
*
|
||||||
|
* Every level carries a line saying who this affects — the labels alone don't
|
||||||
|
* say that visibility is about what others on a shared calendar see.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun VisibilityPickerDialog(
|
private fun VisibilityPickerDialog(
|
||||||
@@ -1761,6 +2039,7 @@ private fun VisibilityPickerDialog(
|
|||||||
options = AccessLevel.entries.toList(),
|
options = AccessLevel.entries.toList(),
|
||||||
selected = selected,
|
selected = selected,
|
||||||
label = { stringResource(accessLevelLabel(it)) },
|
label = { stringResource(accessLevelLabel(it)) },
|
||||||
|
summary = { stringResource(accessLevelSummary(it)) },
|
||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
leading = { Icon(imageVector = accessLevelIcon(it), contentDescription = null) },
|
leading = { Icon(imageVector = accessLevelIcon(it), contentDescription = null) },
|
||||||
@@ -1853,6 +2132,14 @@ private fun accessLevelLabel(level: AccessLevel): Int = when (level) {
|
|||||||
AccessLevel.Confidential -> R.string.event_access_confidential
|
AccessLevel.Confidential -> R.string.event_access_confidential
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What each level means for people the calendar is shared with. */
|
||||||
|
private fun accessLevelSummary(level: AccessLevel): Int = when (level) {
|
||||||
|
AccessLevel.Default -> R.string.event_access_default_summary
|
||||||
|
AccessLevel.Public -> R.string.event_access_public_summary
|
||||||
|
AccessLevel.Private -> R.string.event_access_private_summary
|
||||||
|
AccessLevel.Confidential -> R.string.event_access_confidential_summary
|
||||||
|
}
|
||||||
|
|
||||||
/** Humanise a reminder lead time, mirroring the detail screen's rendering. */
|
/** Humanise a reminder lead time, mirroring the detail screen's rendering. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes)
|
private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes)
|
||||||
@@ -1931,6 +2218,21 @@ private fun EditCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "2:00 PM – 3:00 PM", honouring the user's 12/24-hour setting. Collapses to one
|
||||||
|
* time when both land on the same minute (an instant event shouldn't read as a
|
||||||
|
* range against itself). Dates are deliberately absent — the rows above carry
|
||||||
|
* them.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Locale): String {
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
val timeFormat = remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }
|
||||||
|
val from = timeFormat.format(start.time.toJavaLocalTime())
|
||||||
|
val to = timeFormat.format(end.time.toJavaLocalTime())
|
||||||
|
return if (from == to) from else "$from – $to"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Borderless text input used inside the cards (and as the headline title).
|
* Borderless text input used inside the cards (and as the headline title).
|
||||||
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
|
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
|
||||||
@@ -2035,6 +2337,7 @@ private fun CalendarPicker(
|
|||||||
selectedId: Long?,
|
selectedId: Long?,
|
||||||
onSelect: (Long) -> Unit,
|
onSelect: (Long) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
FullScreenPicker(
|
FullScreenPicker(
|
||||||
title = stringResource(R.string.event_detail_calendar),
|
title = stringResource(R.string.event_detail_calendar),
|
||||||
@@ -2044,6 +2347,7 @@ private fun CalendarPicker(
|
|||||||
calendars = calendars,
|
calendars = calendars,
|
||||||
selectedId = selectedId,
|
selectedId = selectedId,
|
||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
|
onManageCalendars = onManageCalendars,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
|||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
@@ -18,6 +19,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||||
import de.jeanlucmakiola.calendula.domain.problems
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||||
@@ -39,6 +41,8 @@ import kotlinx.coroutines.flow.flowOn
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
@@ -174,18 +178,16 @@ class EventEditViewModel @Inject constructor(
|
|||||||
repository.calendars().catch { emit(emptyList()) }
|
repository.calendars().catch { emit(emptyList()) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writable calendars — the only valid event targets. Disabled calendars are
|
* The calendars a new event can be saved to ([isEventTarget]): writable,
|
||||||
* excluded, so you can't create into a calendar you've removed from the app;
|
* switched on, not a contact-filled mirror, not a non-syncing subscription.
|
||||||
* a last-used preselect landing on a now-disabled calendar falls back to the
|
* A last-used preselect landing on an excluded calendar falls back to the
|
||||||
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
|
* first remaining one (handled by [resolvedCalendarId] and [state]).
|
||||||
* Managed special-dates calendars are excluded too: their events are owned by
|
*
|
||||||
* the contact sync, which would delete any user event created there.
|
* This is the list of *targets*. An event already living in an excluded
|
||||||
|
* calendar keeps it — [state] adds it back to the picker.
|
||||||
*/
|
*/
|
||||||
private val writableCalendars: Flow<List<CalendarSource>> = combine(
|
private val writableCalendars: Flow<List<CalendarSource>> = allCalendars.map { calendars ->
|
||||||
allCalendars,
|
calendars.filter { it.isEventTarget }
|
||||||
prefs.disabledCalendarIds,
|
|
||||||
) { calendars, disabled ->
|
|
||||||
calendars.filter { it.canModifyContents && it.id !in disabled && !it.isManaged }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The target calendar id, resolved exactly as the form shows it. */
|
/** The target calendar id, resolved exactly as the form shows it. */
|
||||||
@@ -219,7 +221,8 @@ class EventEditViewModel @Inject constructor(
|
|||||||
).flowOn(io),
|
).flowOn(io),
|
||||||
colorPalette,
|
colorPalette,
|
||||||
allCalendars,
|
allCalendars,
|
||||||
) { local, external, palette, allCalendars ->
|
settingsPrefs.recentTimeZones.flowOn(io),
|
||||||
|
) { local, external, palette, allCalendars, recentTimeZones ->
|
||||||
val form = local.form ?: return@combine null
|
val form = local.form ?: return@combine null
|
||||||
val resolvedId = form.calendarId
|
val resolvedId = form.calendarId
|
||||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||||
@@ -230,19 +233,27 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// off the calendar's durable marker, not a stored id, so it holds after a
|
// off the calendar's durable marker, not a stored id, so it holds after a
|
||||||
// backup restore too.
|
// backup restore too.
|
||||||
val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true
|
val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true
|
||||||
// The picker offers writable calendars only; when editing a managed event
|
// The event's own calendar is added back whenever it isn't a target
|
||||||
// its own (excluded) calendar is added back so the row still names it.
|
// (managed, or switched off), so the row keeps naming it and saving can
|
||||||
|
// leave the event where it is. A read-only one is still no target.
|
||||||
|
val ownCalendar = resolvedCalendar?.takeIf { own ->
|
||||||
|
own.canModifyContents && external.writable.none { it.id == own.id }
|
||||||
|
}
|
||||||
val pickerCalendars =
|
val pickerCalendars =
|
||||||
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
if (ownCalendar != null) external.writable + ownCalendar else external.writable
|
||||||
else external.writable
|
// An all-day event is date-anchored, so a zone is meaningless on it —
|
||||||
val visibleFields = external.defaultFields + local.revealed
|
// the field is withheld from both lists rather than shown as a no-op.
|
||||||
|
val offerableFields = EventFormField.entries.toSet() -
|
||||||
|
if (resolved.isAllDay) setOf(EventFormField.Timezone) else emptySet()
|
||||||
|
val visibleFields = (external.defaultFields + local.revealed) intersect offerableFields
|
||||||
EventEditUiState(
|
EventEditUiState(
|
||||||
form = resolved,
|
form = resolved,
|
||||||
calendars = pickerCalendars,
|
calendars = pickerCalendars,
|
||||||
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||||
saveState = local.saveState,
|
saveState = local.saveState,
|
||||||
visibleFields = visibleFields,
|
visibleFields = visibleFields,
|
||||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
hiddenFields = (offerableFields - visibleFields).sorted(),
|
||||||
|
recentTimeZones = recentTimeZones,
|
||||||
isEditing = local.editTarget != null,
|
isEditing = local.editTarget != null,
|
||||||
isManaged = isManaged,
|
isManaged = isManaged,
|
||||||
autofocusTitle = external.autofocusTitle,
|
autofocusTitle = external.autofocusTitle,
|
||||||
@@ -260,6 +271,13 @@ class EventEditViewModel @Inject constructor(
|
|||||||
initialValue = null,
|
initialValue = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First day of the week for ordering the recurrence weekday toggles — the
|
||||||
|
* app's "week starts on" preference, matching the calendar views (Auto
|
||||||
|
* falls back to the locale convention).
|
||||||
|
*/
|
||||||
|
val firstDayOfWeek: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
|
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
|
||||||
* from midnight) anchors the start when the form is opened by tapping a slot
|
* from midnight) anchors the start when the form is opened by tapping a slot
|
||||||
@@ -453,12 +471,28 @@ class EventEditViewModel @Inject constructor(
|
|||||||
fun setLocation(value: String) = update { it.copy(location = value) }
|
fun setLocation(value: String) = update { it.copy(location = value) }
|
||||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||||
fun setAllDay(value: Boolean) {
|
fun setAllDay(value: Boolean) {
|
||||||
update { it.copy(isAllDay = value) }
|
// Going all-day drops any pinned zone: the times become bare dates that
|
||||||
|
// the provider stores UTC-anchored, so a zone could only misrepresent
|
||||||
|
// them. Coming back out of all-day leaves the event on the device zone,
|
||||||
|
// which is the same thing a fresh event gets.
|
||||||
|
update { it.copy(isAllDay = value, timezone = if (value) null else it.timezone) }
|
||||||
// The default reminder differs for all-day vs timed; re-apply the
|
// The default reminder differs for all-day vs timed; re-apply the
|
||||||
// type-appropriate default unless the user has hand-edited it (guarded).
|
// type-appropriate default unless the user has hand-edited it (guarded).
|
||||||
applyDefaultReminder()
|
applyDefaultReminder()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin the event to [zoneId], or pass null to follow the device. The zone
|
||||||
|
* rides along as a recent so the picker can offer it without a search next
|
||||||
|
* time — only real picks are remembered, not the device fallback.
|
||||||
|
*/
|
||||||
|
fun setTimezone(zoneId: String?) {
|
||||||
|
update { it.copy(timezone = zoneId) }
|
||||||
|
if (zoneId != null) {
|
||||||
|
viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switching calendars drops any chosen colour: a palette key is
|
* Switching calendars drops any chosen colour: a palette key is
|
||||||
* account-scoped, and a raw colour may be invalid on the new calendar.
|
* account-scoped, and a raw colour may be invalid on the new calendar.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.sourceAppName
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
|
||||||
@@ -61,7 +62,15 @@ private fun FilterList(
|
|||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
groups.forEach { group ->
|
groups.forEach { group ->
|
||||||
Text(
|
Text(
|
||||||
text = group.account,
|
text = if (group.ambiguous) {
|
||||||
|
stringResource(
|
||||||
|
R.string.calendars_account_from_source,
|
||||||
|
group.account,
|
||||||
|
sourceAppName(group.accountType),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
group.account
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
||||||
|
|||||||
@@ -13,9 +13,17 @@ sealed interface FilterUiState {
|
|||||||
data class Success(val groups: List<AccountGroup>) : FilterUiState
|
data class Success(val groups: List<AccountGroup>) : FilterUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Calendars grouped under the account that owns them (Nextcloud / Local / …). */
|
/**
|
||||||
|
* Calendars grouped under the account that owns them (Nextcloud / Local / …).
|
||||||
|
*
|
||||||
|
* [accountType] and [ambiguous] carry what the header needs to tell two
|
||||||
|
* same-named accounts from different apps apart (#77); the label itself is
|
||||||
|
* built in the UI layer, which is where the source app's name can be looked up.
|
||||||
|
*/
|
||||||
data class AccountGroup(
|
data class AccountGroup(
|
||||||
val account: String,
|
val account: String,
|
||||||
|
val accountType: String,
|
||||||
|
val ambiguous: Boolean,
|
||||||
val calendars: List<CalendarRow>,
|
val calendars: List<CalendarRow>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -30,16 +31,14 @@ class FilterViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
repository.calendars(),
|
repository.calendars(),
|
||||||
prefs.hiddenCalendarIds,
|
prefs.hiddenCalendarIds,
|
||||||
prefs.disabledCalendarIds,
|
) { calendars, hidden ->
|
||||||
) { calendars, hidden, disabled ->
|
// Calendars switched off device-wide don't belong in the drawer's
|
||||||
// Disabled calendars are gone from the app entirely — they don't
|
// hide/show list; they live only in Settings → Calendars.
|
||||||
// belong in the drawer's hide/show list (you can't hide what's
|
val enabled = calendars.filter { it.isVisibleInSystem }
|
||||||
// already disabled). They live only in Settings → Calendars.
|
|
||||||
val enabled = calendars.filterNot { it.id in disabled }
|
|
||||||
if (enabled.isEmpty()) {
|
if (enabled.isEmpty()) {
|
||||||
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
} else {
|
} else {
|
||||||
FilterUiState.Success(groupByAccount(enabled, hidden))
|
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
@@ -61,30 +60,27 @@ class FilterViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group calendars under their owning account, preserving the provider's order
|
* Group calendars under their owning account — by name *and* type, so two
|
||||||
* within each group and ordering groups by first appearance. A calendar is
|
* accounts that merely share a name stay apart (#77) — preserving the
|
||||||
* "visible" when its id is *not* in [hidden].
|
* provider's order within each group and ordering groups by first appearance.
|
||||||
|
* A calendar is "visible" when its id is *not* in [hidden].
|
||||||
*/
|
*/
|
||||||
internal fun groupByAccount(
|
internal fun groupCalendarsForFilter(
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
hidden: Set<Long>,
|
hidden: Set<Long>,
|
||||||
): List<AccountGroup> =
|
): List<AccountGroup> =
|
||||||
calendars
|
calendars.groupByAccount().map { group ->
|
||||||
.groupBy { it.accountLabel() }
|
AccountGroup(
|
||||||
.map { (account, cals) ->
|
account = group.label,
|
||||||
AccountGroup(
|
accountType = group.accountType,
|
||||||
account = account,
|
ambiguous = group.ambiguous,
|
||||||
calendars = cals.map { c ->
|
calendars = group.calendars.map { c ->
|
||||||
CalendarRow(
|
CalendarRow(
|
||||||
id = c.id,
|
id = c.id,
|
||||||
displayName = c.displayName,
|
displayName = c.displayName,
|
||||||
color = c.color,
|
color = c.color,
|
||||||
visible = c.id !in hidden,
|
visible = c.id !in hidden,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Account header text: the account name, falling back to its type. */
|
|
||||||
private fun CalendarSource.accountLabel(): String =
|
|
||||||
accountName.takeIf { it.isNotBlank() } ?: accountType.takeIf { it.isNotBlank() } ?: displayName
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ fun ImportScreen(
|
|||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onOpenSingle: (EventForm) -> Unit,
|
onOpenSingle: (EventForm) -> Unit,
|
||||||
forceMany: Boolean = false,
|
forceMany: Boolean = false,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
// Key the VM by the file uri. This screen has no nav backstack, so an
|
// Key the VM by the file uri. This screen has no nav backstack, so an
|
||||||
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
|
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
|
||||||
// across imports — its one-shot `load` guard would then show the *previous*
|
// across imports — its one-shot `load` guard would then show the *previous*
|
||||||
@@ -155,7 +156,12 @@ fun ImportScreen(
|
|||||||
|
|
||||||
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
||||||
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
||||||
is ImportUiState.Many -> ManyContent(s, selected, onSelect = { selected = it })
|
is ImportUiState.Many -> ManyContent(
|
||||||
|
state = s,
|
||||||
|
selected = selected,
|
||||||
|
onSelect = { selected = it },
|
||||||
|
onManageCalendars = onManageCalendars,
|
||||||
|
)
|
||||||
is ImportUiState.Done -> DoneContent(s, onClose)
|
is ImportUiState.Done -> DoneContent(s, onClose)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,10 +169,22 @@ fun ImportScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (Long) -> Unit) {
|
private fun ManyContent(
|
||||||
// No writable calendar to import into — tell the user honestly.
|
state: ImportUiState.Many,
|
||||||
|
selected: Long?,
|
||||||
|
onSelect: (Long) -> Unit,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
// No calendar to import into; carries the same way out the picker's footer
|
||||||
|
// offers below (#76).
|
||||||
if (state.calendars.isEmpty()) {
|
if (state.calendars.isEmpty()) {
|
||||||
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
|
CenteredMessage(
|
||||||
|
message = stringResource(R.string.import_no_calendar),
|
||||||
|
onClose = null,
|
||||||
|
actionLabel = stringResource(R.string.settings_manage_calendars)
|
||||||
|
.takeIf { onManageCalendars != null },
|
||||||
|
onAction = onManageCalendars,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +196,7 @@ private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (L
|
|||||||
calendars = state.calendars,
|
calendars = state.calendars,
|
||||||
selectedId = selected,
|
selectedId = selected,
|
||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
|
onManageCalendars = onManageCalendars,
|
||||||
)
|
)
|
||||||
if (state.warnings.isNotEmpty()) {
|
if (state.warnings.isNotEmpty()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -333,7 +352,12 @@ private fun WarningText(warning: IcsParseWarning) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
|
private fun CenteredMessage(
|
||||||
|
message: String,
|
||||||
|
onClose: (() -> Unit)?,
|
||||||
|
actionLabel: String? = null,
|
||||||
|
onAction: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.padding(24.dp),
|
Modifier.padding(24.dp),
|
||||||
@@ -344,6 +368,9 @@ private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
|
|||||||
if (onClose != null) {
|
if (onClose != null) {
|
||||||
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
|
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
|
||||||
}
|
}
|
||||||
|
if (actionLabel != null && onAction != null) {
|
||||||
|
Button(onClick = onAction) { Text(actionLabel) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.ics.IcsImporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsImporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
@@ -15,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
|||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -57,7 +57,6 @@ sealed interface ImportUiState {
|
|||||||
class ImportViewModel @Inject constructor(
|
class ImportViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
private val importer: IcsImporter,
|
private val importer: IcsImporter,
|
||||||
private val prefs: CalendarPrefs,
|
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -87,16 +86,12 @@ class ImportViewModel @Inject constructor(
|
|||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
)
|
)
|
||||||
else -> {
|
else -> {
|
||||||
// A disabled calendar is removed from the app, so it can't be
|
// The same targets the event form offers ([isEventTarget]).
|
||||||
// an import target — exclude it alongside the read-only ones.
|
|
||||||
// Managed special-dates calendars are contact-derived and
|
|
||||||
// editor-locked, so they're not a valid destination either.
|
|
||||||
val disabled = prefs.disabledCalendarIds.first()
|
|
||||||
ImportUiState.Many(
|
ImportUiState.Many(
|
||||||
events = parsed.events,
|
events = parsed.events,
|
||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
calendars = repository.calendars().first()
|
calendars = repository.calendars().first()
|
||||||
.filter { it.canModifyContents && !it.isManaged && it.id !in disabled },
|
.filter { it.isEventTarget },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What lets the split style's compact grid *become* the full month rather than be
|
||||||
|
* swapped for it (#53).
|
||||||
|
*
|
||||||
|
* The two grids are separate composables — the compact one draws dots, the
|
||||||
|
* expanded one draws the paged style's bars and pills — so nothing about them is
|
||||||
|
* shared by construction. Tagging the pieces that mean the same thing on both
|
||||||
|
* sides with the same [MonthMorphKey] hands Compose enough to animate one into
|
||||||
|
* the other: a day's cell grows, its number rides along, and each dot travels out
|
||||||
|
* to the bar it stood for.
|
||||||
|
*
|
||||||
|
* It travels as a composition local rather than as parameters because the pieces
|
||||||
|
* sit five levels below where the scopes exist, and because a null default is
|
||||||
|
* exactly the right meaning for everyone else: the paged, continuous, and dense
|
||||||
|
* styles share the same row and cell composables, provide nothing, and pay
|
||||||
|
* nothing. Reduced motion provides nothing either, which leaves the plain
|
||||||
|
* cross-fade underneath.
|
||||||
|
*/
|
||||||
|
internal sealed interface MonthMorphKey {
|
||||||
|
/** A day's background pill — the structural anchor the rest rides on. */
|
||||||
|
data class Cell(val date: LocalDate) : MonthMorphKey
|
||||||
|
|
||||||
|
data class DayNumber(val date: LocalDate) : MonthMorphKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event *on one day*. Keyed by date as well as instance because a
|
||||||
|
* multi-day event has a dot on every day it covers but only one bar, drawn
|
||||||
|
* from where it starts: the start day's dot is the one that becomes the bar,
|
||||||
|
* and the rest are left unmatched on purpose. They fade where they stand
|
||||||
|
* while the bar sweeps out over them, which is the honest reading — one of
|
||||||
|
* them could not become the bar without the others teleporting into it.
|
||||||
|
*/
|
||||||
|
data class Event(val date: LocalDate, val instanceId: Long) : MonthMorphKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A day's "+N" marker. The compact grid writes it as a count beside the dots
|
||||||
|
* and the expanded one as a row of small dots under the bars, but they stand
|
||||||
|
* for the same events and appear on exactly the same days — both sides seat
|
||||||
|
* three and overflow the rest — so they are a matched pair, not two unrelated
|
||||||
|
* bits of text. Tagged, the marker travels with its day like everything else
|
||||||
|
* in the cell.
|
||||||
|
*/
|
||||||
|
data class Overflow(val date: LocalDate) : MonthMorphKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selected day's outline. The expanded grid draws no outline — once the
|
||||||
|
* cells carry real event bars it is one mark too many — so this pairs the
|
||||||
|
* compact grid's outline with an *invisible* stand-in over the same cell
|
||||||
|
* there. Without one it had nowhere to travel and could only fade in place
|
||||||
|
* while everything around it moved.
|
||||||
|
*/
|
||||||
|
data class Selection(val date: LocalDate) : MonthMorphKey
|
||||||
|
|
||||||
|
/** The grab handle, which slides from the pane's seam to the foot of the grid. */
|
||||||
|
data object Handle : MonthMorphKey
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
internal class MonthMorphScope(
|
||||||
|
val shared: SharedTransitionScope,
|
||||||
|
val visibility: AnimatedVisibilityScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal val LocalMonthMorph = compositionLocalOf<MonthMorphScope?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the grid is mid-morph, for content that has to stop clipping to let
|
||||||
|
* the travelling pieces through.
|
||||||
|
*
|
||||||
|
* A mark's two homes are in *different rows*: the dot for the 20th sits a third
|
||||||
|
* of the way down the compact grid, its bar most of the way down the expanded
|
||||||
|
* one. Clipped to the row it is arriving at, a mark spends the first half of its
|
||||||
|
* journey outside those bounds and is simply not drawn — so it appears from
|
||||||
|
* nowhere, part-way through, already near its destination. Rendering in place
|
||||||
|
* rather than in an overlay is what subjects them to that clip, and is worth
|
||||||
|
* keeping; the clip is what has to yield, and only while pieces are in flight.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun morphInFlight(): Boolean {
|
||||||
|
val morph = LocalMonthMorph.current ?: return false
|
||||||
|
return morph.shared.isTransitionActive
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag content that is *the same thing* on both sides — a background pill, a day
|
||||||
|
* number — so it animates between its two positions and sizes.
|
||||||
|
*
|
||||||
|
* ### Why nothing renders in the overlay
|
||||||
|
*
|
||||||
|
* By default a travelling piece is painted into an overlay above the *entire*
|
||||||
|
* regular tree, so it can fly over anything in its way. That is right for a
|
||||||
|
* thumbnail crossing a screen, and wrong here: everything is moving inside one
|
||||||
|
* grid, and the overlay meant every untagged neighbour — the selection outline,
|
||||||
|
* the "+N" markers — spent the transition buried under pieces that had left the
|
||||||
|
* tree's z-order behind. Lifting each of them out in turn fixed the burying and
|
||||||
|
* bought a worse problem: they then floated over the grid on their own layer,
|
||||||
|
* out of step with it.
|
||||||
|
*
|
||||||
|
* Rendering in place puts everything back in one z-order and one clip, which is
|
||||||
|
* what makes the grid read as a single surface changing shape rather than a
|
||||||
|
* stack of pieces sliding past each other. Nothing here needs to escape its
|
||||||
|
* ancestors: the cells, marks and markers all travel within the grid.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
|
||||||
|
val morph = LocalMonthMorph.current ?: return this
|
||||||
|
return with(morph.shared) {
|
||||||
|
this@morphElement.sharedElement(
|
||||||
|
sharedContentState = rememberSharedContentState(key),
|
||||||
|
animatedVisibilityScope = morph.visibility,
|
||||||
|
renderInOverlayDuringTransition = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag content that means the same thing but *is drawn differently* on each side —
|
||||||
|
* a 5dp dot and a titled bar — so only the bounds are shared and the contents
|
||||||
|
* cross-fade inside them.
|
||||||
|
*
|
||||||
|
* [ResizeMode.RemeasureToBounds][SharedTransitionScope.ResizeMode] rather than
|
||||||
|
* scaling: a bar's title laid out at the dot's 5dp and then scaled up would
|
||||||
|
* arrive as a smear. Remeasuring keeps the text at its real size throughout and
|
||||||
|
* simply clips it while there is no room, so what grows is the pill, not the type.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
|
||||||
|
val morph = LocalMonthMorph.current ?: return this
|
||||||
|
return with(morph.shared) {
|
||||||
|
this@morphBounds.sharedBounds(
|
||||||
|
sharedContentState = rememberSharedContentState(key),
|
||||||
|
animatedVisibilityScope = morph.visibility,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||||
|
renderInOverlayDuringTransition = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user