Compare commits
194
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
883e8fd101 | ||
|
|
11d1147931 | ||
|
|
836817cd12 | ||
|
|
9c35712573 | ||
|
|
4342c6ab6d | ||
|
|
57bad5c74c | ||
|
|
ebf833d0bd | ||
|
|
2f24a9878c | ||
|
|
2313671965 | ||
|
|
e1b80d9f89 | ||
|
|
99bb6adf80 | ||
|
|
06ee010f07 | ||
|
|
5f07adef2f | ||
|
|
bfc05795f0 | ||
|
|
9de5f3f00d | ||
|
|
bfdbd49aa1 | ||
|
|
844a3c22ff | ||
|
|
87a08a4d24 | ||
|
|
dc0637c37c | ||
|
|
381eca6660 | ||
|
|
36018b3e52 | ||
|
|
3c0324c071 | ||
|
|
a70108dff8 | ||
|
|
83447ab68a | ||
|
|
4ac937c54f | ||
|
|
b5f28929d7 | ||
|
|
d729a928ea | ||
|
|
01a4ad742b | ||
|
|
6f77a5e12e | ||
|
|
e29c38d88c | ||
|
|
04f509fc4c | ||
|
|
b6061f1384 | ||
|
|
38c3a82b54 | ||
|
|
d3f1409503 | ||
|
|
3475a073ad | ||
|
|
643c61db65 | ||
|
|
1734b7863c | ||
|
|
d45d4f443b | ||
|
|
994fa010b8 | ||
|
|
5c20c5f6eb | ||
|
|
c4bb7ae0b2 | ||
|
|
0c985a280c | ||
|
|
17ebb9fd42 | ||
|
|
cadc2b14db | ||
|
|
1fdca903be | ||
|
|
5e7658aa80 | ||
|
|
bea31032fb | ||
|
|
f7c1dbdbc4 | ||
|
|
a1ed010963 | ||
|
|
7be6b7ac31 | ||
|
|
20c20b71ea | ||
|
|
0fb3df0b3a | ||
|
|
e10b09a9b5 | ||
|
|
806a576380 | ||
|
|
3c365c6792 | ||
|
|
4fb4a85ac4 | ||
|
|
e7312e5d6e | ||
|
|
12c2de7ed8 | ||
|
|
8a3cc95c66 | ||
|
|
9c06ffdb48 | ||
|
|
870a903015 | ||
|
|
b5768e3b17 | ||
|
|
02b33dbf07 | ||
|
|
a0c0b875f0 | ||
|
|
512bf8c746 | ||
|
|
04257e8a66 | ||
|
|
c04b2ecee2 | ||
|
|
e10fae0710 | ||
|
|
b3fe98907e | ||
|
|
b19493e925 | ||
|
|
19b4cf06b2 | ||
|
|
f9a5618b84 | ||
|
|
822a9799db | ||
|
|
eb4fd3108e | ||
|
|
4c86c261ba | ||
|
|
dc5c9db68a | ||
|
|
befcad586e | ||
|
|
9f950cba9f | ||
|
|
51a1d1e8ef | ||
|
|
631c9da0a4 | ||
|
|
5fa214b9ba | ||
|
|
eb406c3132 | ||
|
|
3a90e94958 | ||
|
|
248842871b | ||
|
|
1cab023abc | ||
|
|
9ca29bb797 | ||
|
|
13640751d2 | ||
|
|
0c7d1a97e2 | ||
|
|
d0202783d4 | ||
|
|
f72f810e4f | ||
|
|
13f4c86113 | ||
|
|
65d9f95717 | ||
|
|
19fa8203f1 | ||
|
|
0f1df4800d | ||
|
|
7351b2d35c | ||
|
|
1a66a31563 | ||
|
|
fe3c659804 | ||
|
|
a2172dce12 | ||
|
|
4099f66e91 | ||
|
|
49112b11b3 | ||
|
|
1218bb523c | ||
|
|
17600a3905 | ||
|
|
db5104a7d0 | ||
|
|
6306064fc3 | ||
|
|
d5a4761f2c | ||
|
|
66d6681798 | ||
|
|
fb61169656 | ||
|
|
62079fba0b | ||
|
|
cc0fc8edc3 | ||
|
|
62174fcd53 | ||
|
|
55c1b2ac25 | ||
|
|
0ccc610144 | ||
|
|
5d009b7dc0 | ||
|
|
28924daf02 | ||
|
|
e71fb2a625 | ||
|
|
aa5f76fc1b | ||
|
|
5b099714f6 | ||
|
|
163d821d1f | ||
|
|
fe98b1ed66 | ||
|
|
c0270ab3a0 | ||
|
|
6019d6c0b8 | ||
|
|
19bc5967a4 | ||
|
|
ddab3454bb | ||
|
|
247c9ee6cd | ||
|
|
6be170cae2 | ||
|
|
8133584f1e | ||
|
|
4cdb42afba | ||
|
|
19a4c05e93 | ||
|
|
aba06affbf | ||
|
|
2b132a5b7a | ||
|
|
52ecce03b8 | ||
|
|
0eb76db12d | ||
|
|
d03985eada | ||
|
|
34108b9c50 | ||
|
|
49a0c114c2 | ||
|
|
b1ce6f85cc | ||
|
|
ca5f87af11 | ||
|
|
7445092767 | ||
|
|
5ef66120bf | ||
|
|
bda86cd73d | ||
|
|
56953b0c50 | ||
|
|
7c8cd1e145 | ||
|
|
bce36956d1 | ||
|
|
e0fdadc7ca | ||
|
|
136167b54c | ||
|
|
02fd599e6b | ||
|
|
b0412a96f3 | ||
|
|
06dbb89868 | ||
|
|
b002f45f29 | ||
|
|
c1eb702384 | ||
|
|
307517e85a | ||
|
|
a8ba227e8b | ||
|
|
cbfdfb9710 | ||
|
|
d85003416e | ||
|
|
61c516149e | ||
|
|
560225cb8c | ||
|
|
2e8367afd4 | ||
|
|
8c40109341 | ||
|
|
4259c996b6 | ||
|
|
d4218227bd | ||
|
|
6a4c5084a5 | ||
|
|
e38b9cde1f | ||
|
|
dbae8b5bcf | ||
|
|
2d06b7ad32 | ||
|
|
10a674deba | ||
|
|
93b9aa8f34 | ||
|
|
ca826737ed | ||
|
|
5365cc67eb | ||
|
|
25e3640ba0 | ||
|
|
3f85bac74e | ||
|
|
5b83b9da73 | ||
|
|
3c8c555b94 | ||
|
|
a6a6c0c714 | ||
|
|
66dbdc8913 | ||
|
|
6a125b7c73 | ||
|
|
44233cff35 | ||
|
|
5764bd889b | ||
|
|
a8a83a39d0 | ||
|
|
9557ca73ed | ||
|
|
9767fbbbaf | ||
|
|
9054742503 | ||
|
|
3d62036d79 | ||
|
|
34a8e91ea5 | ||
|
|
0097a9c534 | ||
|
|
4e51e33356 | ||
|
|
e8657117d6 | ||
|
|
d5c53df6b5 | ||
|
|
2f4e05899a | ||
|
|
06653bf5b1 | ||
|
|
e952393116 | ||
|
|
384ba39cf9 | ||
|
|
3fca28810b | ||
|
|
c988e1f028 | ||
|
|
a3d136f55d |
@@ -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 / Google Play / Codeberg APK or Obtainium / 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 -->
|
||||
|
||||
@@ -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. 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.
|
||||
@@ -1,20 +1,20 @@
|
||||
---
|
||||
name: Crash report
|
||||
about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form.
|
||||
about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash). It copies the report to your clipboard and prefills this form.
|
||||
title: "Crash: "
|
||||
labels:
|
||||
- bug
|
||||
- crash
|
||||
- priority:high
|
||||
- priority/high
|
||||
---
|
||||
|
||||
<!--
|
||||
Thanks for reporting a crash in Calendula!
|
||||
|
||||
If the app prefilled this for you, the crash report is already below — just add
|
||||
If the app prefilled this for you, the crash report is already below. Add
|
||||
what you were doing and submit. Otherwise, paste the report from your clipboard
|
||||
into the code block. The report contains only app/Android/device versions and the
|
||||
stack trace — no personal data or calendar content.
|
||||
stack trace, with no personal data or calendar content.
|
||||
-->
|
||||
|
||||
### What happened
|
||||
+1
-1
@@ -9,7 +9,7 @@ labels:
|
||||
### What would you like Calendula to do?
|
||||
|
||||
|
||||
### Why — what problem does it solve?
|
||||
### What problem does it solve?
|
||||
|
||||
|
||||
### Anything else
|
||||
@@ -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,63 @@ jobs:
|
||||
- name: Reproducible-release invariant
|
||||
run: bash scripts/check_reproducible_release.sh
|
||||
|
||||
# Decide whether anything that affects the app build changed. Docs,
|
||||
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
|
||||
# Gradle work below but still report a green `ci`.
|
||||
# versionCode must match versionName (the official F-Droid repo builds the
|
||||
# tag as committed), and a beta must never reach main.
|
||||
- name: Committed version is well-formed
|
||||
env:
|
||||
BASE: ${{ github.base_ref }}
|
||||
run: |
|
||||
set -e
|
||||
bash scripts/version_info.sh --check
|
||||
if [ "${BASE#refs/heads/}" = "main" ] && [ "$(bash scripts/version_info.sh channel)" = "beta" ]; then
|
||||
echo "ERROR: versionName $(bash scripts/version_info.sh version) is a beta." >&2
|
||||
echo "Set the stable version (and its versionCode) before merging into main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Play rejects a "What's New" over 500 characters, which would fail the
|
||||
# upload after the release had already shipped everywhere else. Cheap, so
|
||||
# it runs on every PR rather than only on the release merge. A beta ships
|
||||
# no What's New, so only the older files are checked for one.
|
||||
- name: Changelog length invariant
|
||||
run: bash scripts/check_changelog_lengths.sh
|
||||
|
||||
# The whole listing goes to Play with every release; a field over its cap
|
||||
# or an image Play rejects would fail that upload.
|
||||
- name: Store listing invariant
|
||||
run: |
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update && apt-get install -y python3
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache python3
|
||||
fi
|
||||
fi
|
||||
python3 scripts/check_store_listing.py
|
||||
|
||||
# Decide whether anything that affects the app build changed. Docs, store
|
||||
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
||||
# the SDK + Gradle work below but still report a green `ci`.
|
||||
- name: Classify change 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: |
|
||||
set -e
|
||||
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
|
||||
# the PR branch forked several commits back; a shallow tip has no merge
|
||||
# base with a divergent branch and `git diff base...HEAD` aborts.
|
||||
@@ -58,11 +107,15 @@ jobs:
|
||||
fi
|
||||
CHANGED=$(git diff --name-only "$MB" HEAD)
|
||||
echo "Changed files:"; echo "$CHANGED"
|
||||
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then
|
||||
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"
|
||||
else
|
||||
echo "code=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Docs/metadata-only change — skipping the Android build."
|
||||
echo "code=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Java
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
*.jpg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
app/src/test/resources/ics/*.ics -text
|
||||
|
||||
@@ -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:
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Android build environment
|
||||
description: JDK 17, Android SDK + build tools, Gradle cache and jq for the release and beta pipelines.
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: ''
|
||||
|
||||
- name: Setup Android SDK cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /opt/android-sdk
|
||||
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
||||
|
||||
- name: Install Android SDK packages
|
||||
shell: bash
|
||||
run: |
|
||||
yes | sdkmanager --licenses >/dev/null || true
|
||||
sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-37.0" \
|
||||
"build-tools;36.0.0"
|
||||
|
||||
- name: Setup Gradle cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Install jq
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
SUDO=""
|
||||
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y jq
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
$SUDO apk add --no-cache jq
|
||||
fi
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
shell: bash
|
||||
run: chmod +x ./gradlew
|
||||
@@ -0,0 +1,97 @@
|
||||
name: Beta — Codeberg pre-release
|
||||
|
||||
# Pushing a release/* branch whose versionName is X.Y.Z-beta.N publishes a
|
||||
# Codeberg pre-release (APK + SHA-256) and a Gitea pre-release (R8 mapping).
|
||||
# Betas never reach F-Droid or Play. See docs/RELEASING.md.
|
||||
on:
|
||||
push:
|
||||
branches: ['release/**']
|
||||
|
||||
concurrency:
|
||||
group: beta
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
detect:
|
||||
# Gitea only; see the same guard in release.yaml.
|
||||
if: github.repository_owner == 'makiolaj'
|
||||
runs-on: docker
|
||||
outputs:
|
||||
is_beta: ${{ steps.v.outputs.is_beta }}
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve version and whether it still needs publishing
|
||||
id: v
|
||||
run: |
|
||||
set -e
|
||||
INFO=$(bash scripts/version_info.sh)
|
||||
echo "$INFO"
|
||||
echo "$INFO" >> "$GITHUB_OUTPUT"
|
||||
if [ "$(bash scripts/version_info.sh channel)" != beta ]; then
|
||||
echo "Not a beta — nothing to do."
|
||||
echo "is_beta=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
GATE=$(bash scripts/release_gate.sh)
|
||||
echo "is_beta=${GATE#cut=}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
beta:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.is_beta == 'true'
|
||||
runs-on: docker
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
VERSION: ${{ needs.detect.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Android build environment
|
||||
uses: ./.gitea/actions/android-env
|
||||
|
||||
- name: Pin versionCode to versionName
|
||||
run: bash scripts/version_info.sh --pin
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew testDebugUnitTest
|
||||
|
||||
# The real app key, so a beta updates in place to later betas and stable.
|
||||
- name: Setup Android Keystore
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||
run: bash scripts/write_keystore.sh
|
||||
|
||||
- name: Build release APK
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
- name: Create tag + Gitea pre-release
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||
cat release-notes.md
|
||||
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
|
||||
MAPPING=app/build/outputs/mapping/release/mapping.txt \
|
||||
bash scripts/publish_gitea_release.sh
|
||||
|
||||
- name: Publish pre-release to Codeberg
|
||||
env:
|
||||
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
|
||||
API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
|
||||
APK=app/build/outputs/apk/release/app-release.apk \
|
||||
bash scripts/publish_codeberg_release.sh
|
||||
+207
-255
@@ -1,4 +1,4 @@
|
||||
name: Release — F-Droid repo + Gitea/Codeberg release
|
||||
name: Release — F-Droid repo + Gitea/Codeberg release + Play
|
||||
|
||||
# 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,
|
||||
@@ -7,7 +7,15 @@ name: Release — F-Droid repo + Gitea/Codeberg release
|
||||
# that release to Codeberg with the signed APK + a SHA-256 checksum as a
|
||||
# direct-download channel — the tag is an output of the pipeline, not its
|
||||
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
||||
# nothing.
|
||||
# nothing. Betas (X.Y.Z-beta.N) never come through here: beta.yaml cuts them
|
||||
# from release/* branches as Codeberg-only pre-releases, and `detect` refuses
|
||||
# one that reaches main.
|
||||
#
|
||||
# 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
|
||||
# path: it re-signs the existing F-Droid index with the repo key and re-uploads,
|
||||
@@ -48,56 +56,28 @@ jobs:
|
||||
|
||||
- name: Resolve version and whether it is a new release
|
||||
id: v
|
||||
env:
|
||||
# Tags are read from Codeberg, which is canonical — deliberately NOT
|
||||
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
|
||||
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
|
||||
# source does not have. A tag minted here on Gitea is therefore wiped
|
||||
# by the next sync (Codeberg does not have it yet) and only reappears
|
||||
# once the tag push at the end of this workflow propagates back.
|
||||
# Asking Gitea inside that window would report "no tag" for a release
|
||||
# that already shipped, and cut it a second time.
|
||||
# Public repo, so this read needs no token.
|
||||
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
|
||||
run: |
|
||||
set -e
|
||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||||
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
||||
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved version $VERSION (code $VERSION_CODE)"
|
||||
INFO=$(bash scripts/version_info.sh)
|
||||
echo "$INFO"
|
||||
echo "$INFO" >> "$GITHUB_OUTPUT"
|
||||
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
|
||||
CHANNEL=$(echo "$INFO" | sed -n 's/^channel=//p')
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "Manual dispatch — re-sign path, not a release."
|
||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# A tag for this version already existing means the release shipped on
|
||||
# an earlier push; do nothing. Absent => this merge cuts the release.
|
||||
#
|
||||
# Anything other than a clean 200/404 is treated as fatal rather than
|
||||
# as "no tag". A Codeberg outage or a network blip would otherwise
|
||||
# read as absent and re-cut a release that has already shipped —
|
||||
# republishing to F-Droid and Play. Failing here is recoverable; a
|
||||
# duplicate release is not.
|
||||
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
|
||||
case "$STATUS" in
|
||||
200)
|
||||
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
|
||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
404)
|
||||
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
|
||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
|
||||
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# Backstop for CI's guard: betas ship from release/* via beta.yaml.
|
||||
if [ "$CHANNEL" != "stable" ]; then
|
||||
echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Tags are read from Codeberg, the canonical forge: a tag minted here is
|
||||
# wiped by the next mirror sync until Codeberg has it. A lookup error is
|
||||
# fatal, since guessing "no tag" would re-cut a shipped release.
|
||||
GATE=$(bash scripts/release_gate.sh)
|
||||
echo "is_release=${GATE#cut=}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# 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
|
||||
@@ -118,65 +98,13 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
- name: Android build environment
|
||||
uses: ./.gitea/actions/android-env
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: ''
|
||||
|
||||
- name: Setup Android SDK cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /opt/android-sdk
|
||||
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
||||
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
yes | sdkmanager --licenses >/dev/null || true
|
||||
sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-37.0" \
|
||||
"build-tools;36.0.0"
|
||||
|
||||
- name: Setup Gradle cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Install jq
|
||||
run: |
|
||||
set -e
|
||||
SUDO=""
|
||||
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y jq
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
$SUDO apk add --no-cache jq
|
||||
fi
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
# The committed versionName is the source of truth. Pin versionCode to the
|
||||
# value derived from it so the published APK's code is always
|
||||
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
|
||||
# The committed versionName is the source of truth; pin the derived code.
|
||||
- name: Pin versionCode to versionName
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: |
|
||||
set -e
|
||||
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
|
||||
grep -E 'versionName|versionCode' app/build.gradle.kts
|
||||
run: bash scripts/version_info.sh --pin
|
||||
|
||||
# Test the exact commit being shipped (only on a real release).
|
||||
- name: Unit tests
|
||||
@@ -189,15 +117,7 @@ jobs:
|
||||
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||
run: |
|
||||
mkdir -p app
|
||||
echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks
|
||||
cat > key.properties <<EOF
|
||||
storePassword=$KEY_PASSWORD
|
||||
keyPassword=$KEY_PASSWORD
|
||||
keyAlias=$KEY_ALIAS
|
||||
storeFile=upload-keystore.jks
|
||||
EOF
|
||||
run: bash scripts/write_keystore.sh
|
||||
|
||||
- name: Build release APK
|
||||
if: env.IS_RELEASE == 'true'
|
||||
@@ -258,9 +178,10 @@ jobs:
|
||||
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
|
||||
|
||||
# Per-version "What's New": ensure this version's changelog exists in the
|
||||
# fastlane tree (committed at release-cut time for the official repo; this
|
||||
# regenerates it from CHANGELOG.md so the self-hosted repo never depends on
|
||||
# the commit having happened). The transform below then carries it across.
|
||||
# fastlane tree. The committed hand-written summary (kept under Play's
|
||||
# 500-char cap) is used as-is; only if it is missing does the script fall
|
||||
# back to CHANGELOG.md, so the self-hosted repo never depends on the
|
||||
# commit having happened. The transform below then carries it across.
|
||||
- name: Ensure this version's changelog is in the fastlane tree
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: bash scripts/sync_changelog_to_fastlane.sh
|
||||
@@ -305,6 +226,7 @@ jobs:
|
||||
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
|
||||
# this commit, so the tag only ever marks a fully-shipped release (and a
|
||||
# failure before here leaves no tag, so re-running the workflow retries).
|
||||
# Also attaches the R8 mapping, best-effort.
|
||||
- name: Create tag + Gitea release
|
||||
if: env.IS_RELEASE == 'true'
|
||||
env:
|
||||
@@ -313,74 +235,10 @@ jobs:
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
TAG="v$VERSION"
|
||||
# Notes = this version's CHANGELOG section.
|
||||
awk -v ver="$VERSION" '
|
||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||
/^## \[/ { flag = 0 }
|
||||
flag' CHANGELOG.md > release-notes.md
|
||||
sed -i -e '/./,$!d' release-notes.md
|
||||
if [ ! -s release-notes.md ]; then
|
||||
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
|
||||
fi
|
||||
python3 - "$TAG" "$SHA" <<'PY' > payload.json
|
||||
import json, sys
|
||||
print(json.dumps({
|
||||
"tag_name": sys.argv[1],
|
||||
"target_commitish": sys.argv[2],
|
||||
"name": sys.argv[1],
|
||||
"body": open("release-notes.md").read(),
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}))
|
||||
PY
|
||||
# Upsert (re-run safe): PATCH if a release for the tag already exists,
|
||||
# else POST a new one (which also creates the tag at target_commitish).
|
||||
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
|
||||
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
|
||||
if [ -n "$ID" ]; then
|
||||
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @payload.json "$API/releases/$ID")
|
||||
OK=200
|
||||
else
|
||||
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @payload.json "$API/releases")
|
||||
OK=201
|
||||
fi
|
||||
cat response.json
|
||||
if [ "$CODE" != "$OK" ]; then
|
||||
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Created/updated release $TAG at $SHA"
|
||||
|
||||
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
|
||||
# Attached to the release (it's not an APK, so it fits the no-binaries
|
||||
# rule). Best-effort: never fail a release over it.
|
||||
- name: Attach R8 mapping to Gitea release
|
||||
if: env.IS_RELEASE == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
run: |
|
||||
set -e
|
||||
MAP="app/build/outputs/mapping/release/mapping.txt"
|
||||
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
|
||||
TAG="v$VERSION"
|
||||
ASSET="mapping-${VERSION}.txt.gz"
|
||||
gzip -c "$MAP" > "/tmp/$ASSET"
|
||||
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
||||
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
|
||||
# Replace any prior asset of the same name (re-run safe).
|
||||
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
||||
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
|
||||
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@/tmp/$ASSET" \
|
||||
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
|
||||
bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||
TAG="v$VERSION" PRERELEASE=false NOTES_FILE=release-notes.md \
|
||||
MAPPING=app/build/outputs/mapping/release/mapping.txt \
|
||||
bash scripts/publish_gitea_release.sh
|
||||
|
||||
# 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 +
|
||||
@@ -399,81 +257,175 @@ jobs:
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
if [ -z "${TOKEN:-}" ]; then
|
||||
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
|
||||
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||
TAG="v$VERSION" PRERELEASE=false NOTES_FILE=release-notes.md \
|
||||
APK=app/build/outputs/apk/release/app-release.apk \
|
||||
bash scripts/publish_codeberg_release.sh
|
||||
|
||||
# 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. `production` — the release itself is already the
|
||||
# gate (a bumped versionName only reaches main after on-device review), so
|
||||
# a second manual promotion in the Play Console bought nothing but delay.
|
||||
# Override with the PLAY_TRACK repo variable to stage a release instead.
|
||||
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'production' }}
|
||||
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
|
||||
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" )
|
||||
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"
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# 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."
|
||||
- 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
|
||||
AAB="$GITHUB_WORKSPACE/dist/app-release.aab"
|
||||
# Absolute, because a lane body runs from fastlane/, not the
|
||||
# workspace root — a relative path resolves against the wrong
|
||||
# directory there and 2.17.1 died on exactly that.
|
||||
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
|
||||
bundle exec fastlane deploy \
|
||||
aab:"$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
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
name: Renovate
|
||||
|
||||
on:
|
||||
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
|
||||
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
|
||||
# Every merge to main. Mirror syncs from Codeberg fire push events here (the
|
||||
# same trigger release.yaml relies on), so a merged Renovate PR is followed
|
||||
# within minutes by a run that rebases the sibling PRs it just conflicted —
|
||||
# most bumps touch gradle/libs.versions.toml. Outside renovate.json5's
|
||||
# `schedule` window such a run only maintains existing branches
|
||||
# (updateNotScheduled), it never opens new PRs. Renovate's own rebases push
|
||||
# to renovate/* branches, not main, so this cannot loop.
|
||||
push:
|
||||
branches: [main]
|
||||
# Weekly sweep for new updates. Mondays 05:00 UTC, inside the schedule window
|
||||
# in renovate.json5 — keep the two in step.
|
||||
schedule:
|
||||
- cron: '0 5 * * 1'
|
||||
# Manual run for an on-demand sweep from the Actions tab.
|
||||
|
||||
+16
@@ -50,11 +50,27 @@ google-services.json
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor swap/backup files
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# F-Droid local artifacts (the pipeline generates them in CI)
|
||||
/fdroid/
|
||||
|
||||
# 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.md
|
||||
|
||||
+29
-8
@@ -17,9 +17,10 @@ re-inventing the calendar sync stack — leave that to DAVx5 and the system.
|
||||
## Current Milestone
|
||||
|
||||
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
|
||||
(power-user features) plus an undecided "Locations & People" idea backlog;
|
||||
see `ROADMAP.md`.
|
||||
delivery) are **complete** — v2.0.0 shipped 2026-06-11. Everything since is
|
||||
tracked as issues and milestones on Codeberg:
|
||||
<https://codeberg.org/jlmakiola/calendula/milestones>. A milestone maps to its
|
||||
`release/vX.Y.Z` branch.
|
||||
|
||||
## 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
|
||||
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
|
||||
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 —
|
||||
any feature that would need one is an explicit product decision first.
|
||||
The shared Material 3 Expressive kit, **floret-kit**, is a git submodule wired
|
||||
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
|
||||
|
||||
@@ -40,5 +57,9 @@ shows a stylized "1" on a slate squircle.
|
||||
|
||||
## Source
|
||||
|
||||
Hosted on self-hosted Gitea, released through self-hosted F-Droid repo on
|
||||
Hetzner. Same infrastructure as `HouseHoldKeaper`.
|
||||
**Codeberg (`jlmakiola/calendula`) is canonical** — git, issues, PRs, tags and
|
||||
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**.
|
||||
+722
@@ -7,13 +7,651 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Edits and deletions show up straight away on every phone.** On some phones, Samsung among them, an edited, moved or deleted event stayed where it was until the app was restarted, so a second edit could act on an event that had already changed. Calendula now refreshes its views itself after every change, and again when you come back to the app ([#364]).
|
||||
- **The home-screen widgets do less work per update.** The month widget loads the months around the one it shows instead of more than two years of events, and the agenda widget only its chosen range. This should help widgets that stayed on "loading" on slower phones ([#363]).
|
||||
|
||||
## [2.22.3] — 2026-10-02
|
||||
|
||||
### Fixed
|
||||
- **The store listing is called "Calendula: Calendar" again,** with "Calendar" in each store language. Since 2.22.0 the Play listing comes from the repository, whose title was only "Calendula". The app is unchanged ([#361]).
|
||||
|
||||
## [2.22.2] — 2026-10-02
|
||||
|
||||
### Fixed
|
||||
- **2.22 reaches Google Play.** The Dutch store listing used the locale code `nl`, which Play rejects, so the Play uploads of 2.22.0 and 2.22.1 failed. It now uses `nl-NL`, and CI checks every store locale against the codes Play accepts. The app is unchanged from 2.22.1 ([#359]).
|
||||
|
||||
## [2.22.1] — 2026-10-02
|
||||
|
||||
### Fixed
|
||||
- **Tapping a link in an event's location opens it in the browser instead of a maps app.** Links are underlined, as in the description, and tapping the rest of the location still opens maps. A link with a capitalised `Https://`, or wrapped in `<…>` the way Outlook and Teams write them, opens correctly too. When no app can open a link, a short message says so instead of the tap doing nothing ([#357]).
|
||||
|
||||
## [2.22.0] — 2026-10-01
|
||||
|
||||
### Added
|
||||
- **Day, Week, and Month all page with a swipeable pager that follows your finger** instead of cutting between dates ([#131], [#336]).
|
||||
- **Pages can open the split agenda.** Swipe up from a full month to reveal the compact grid and month agenda; tap a day to filter it, then swipe down to return. The transition gives haptic feedback ([#111]).
|
||||
- **A multi-day view shows a configurable number of days side by side,** with today in the first column. Set the day count in Settings ([#193]).
|
||||
- **A "Visible hours" setting fits a chosen range of hours to the screen** when Day, Week, or the new multi-day view opens; earlier and later hours stay a scroll away ([#322]).
|
||||
- **Timeline views mark events that run outside the visible hours** with a coloured edge at the top or bottom, and keep them tappable underneath it ([#188]).
|
||||
- **Week numbers open their week.** Tap a week number in the month grid to open Week at that row; Back returns to Month ([#197]).
|
||||
|
||||
### Changed
|
||||
- **Day, multi-day, and Week share one timeline and morph into each other** when you switch views, instead of each view rebuilding its own ([#184]).
|
||||
- **The back gesture previews the view you're returning to** and springs back to the current one if you cancel the gesture ([#185]).
|
||||
- **Split view opens with the month's agenda.** It starts at today in the current month; tapping a day filters the list, and the filter chip restores the full month. Swiping the list moves by day when filtered and by month otherwise ([#142]).
|
||||
- **Today stands out in the month grid.** Its whole cell has a distinct background; tapping Today briefly expands that cell and nudges its neighbours aside ([#241]).
|
||||
- **All-day reminders due today or tomorrow say so** instead of spelling out the date ([#308]).
|
||||
- **Store listing in every app language.** Google Play and F-Droid show the description in all 16 languages the app ships, and each release publishes the Play listing from the repository ([#285]).
|
||||
|
||||
### Fixed
|
||||
- **Titles and locations stop growing at 255 characters.** Some CalDAV servers (Open-Xchange among them) silently truncate longer values on sync; Calendula now caps them first and shows a counter once you're close ([#331]).
|
||||
- **The "Calendar" launcher alias is translatable.** The "App name" setting's alternate label was hard-coded to English regardless of the app's language ([#251]).
|
||||
- **Multi-day events stay above shorter events.** Month and Week bars, Split day lists, and Agenda now place multi-day events before one-day all-day events, then timed events; declined events remain last ([#291]).
|
||||
- **Editing one occurrence of a coloured Google series saves again.** Saving with "Only this event" kept you on the edit screen without saving anything ([#338]).
|
||||
|
||||
## [2.21.0] — 2026-09-23
|
||||
|
||||
### Added
|
||||
- **Setup makes sure you have a calendar to write to.** A fresh install could
|
||||
finish onboarding and land on an empty grid with nothing saying why — either
|
||||
because the device had no calendar at all, or because every calendar it had
|
||||
was switched off. Setup now covers both: it offers to create a calendar on
|
||||
this device or to add an account that syncs one, and it lists the calendars
|
||||
that exist but are switched off so you can turn them back on ([#287]).
|
||||
|
||||
### Changed
|
||||
- **The month grid gives its days more room.** Week numbers are set smaller and
|
||||
their gutter is measured from the widest number it has to seat instead of
|
||||
holding a fixed 40dp; event chips spend less of their width on padding; and
|
||||
the labels in both are tracked more tightly, which is worth roughly a
|
||||
character per chip ([#189], [#190], [#212], [#213]).
|
||||
- **A month day shows as many events as it has height for.** The limit was a
|
||||
flat three at every size, so a tall week row threw away space it could have
|
||||
filled, while a cramped one drew a third chip its band could not hold and put
|
||||
the "more" marker below the clip, where nothing showed the day held more at
|
||||
all. It is measured per week row now, and a tablet or a landscape phone seats
|
||||
more of them.
|
||||
- **Split view no longer pads a short month out to six rows.** A five-row month
|
||||
was handing the day pane a blank row and its gap — a sixth of the grid. The
|
||||
grid now draws only the rows its month spans and the pane grows into the rest
|
||||
([#162]).
|
||||
|
||||
### Fixed
|
||||
- **Short events show their title in week and day view.** A block needed 20dp
|
||||
of height before it drew any title at all, which at fit-the-whole-day put the
|
||||
cutoff around the half hour. The padding now tapers as the block shrinks, so
|
||||
a short event keeps its name ([#289]).
|
||||
- **A fully zoomed-out timeline sits still.** Rounding its height up left it as
|
||||
much as a pixel per hour taller than the viewport those hours are meant to
|
||||
fill — some 24px of leftover scroll, enough to bounce off Android's
|
||||
overscroll stretch ([#290]).
|
||||
- **Backup & restore says what is going on instead of coming up blank.** It
|
||||
built its rows straight from the calendar list, so loading, a provider error,
|
||||
and a device with nothing to export all rendered as an empty page with no
|
||||
buttons on it ([#304]).
|
||||
- **An event with no title reads in your language.** It was labelled "(Ohne
|
||||
Titel)" on every screen but the event's own, whatever language the phone was
|
||||
set to: the German text was chosen before the translated one could be
|
||||
([#321]).
|
||||
- **A calendar the system gives no name reads in your language.** The same
|
||||
cause, in the calendar lists and pickers — and because the calendar editor
|
||||
starts from that name, saving such a calendar wrote the German text into it
|
||||
for real ([#329]).
|
||||
|
||||
## [2.20.4] — 2026-09-20
|
||||
|
||||
### Fixed
|
||||
- **The app no longer reopens the event you last came in on.** Opening an event
|
||||
from a reminder or a home-screen widget made the app return to that same
|
||||
event on every later launch — and show an error screen once the event had
|
||||
been deleted. Android hands an app the launch it was started with again each
|
||||
time it is rebuilt, and Calendula was acting on it every time instead of
|
||||
once ([#309]).
|
||||
- **"Today" works in the agenda again.** Scrolling a few days ahead and tapping
|
||||
today did nothing, because the agenda is browsed by scrolling rather than by
|
||||
moving its date, and the button only moved the date. It now brings the list
|
||||
back to today, and it stays offered while the list is scrolled away — before,
|
||||
it was hidden in exactly the situation it is for ([#305]).
|
||||
- **Calendula is filed under "Calendar & Agenda" in the self-hosted repo.**
|
||||
Browsing by category in an F-Droid client set to prefer that repo listed the
|
||||
app under "Time" instead ([#294]).
|
||||
|
||||
## [2.20.3] — 2026-09-12
|
||||
|
||||
### Fixed
|
||||
- **A custom agenda range crashed the app in French.** Choosing a custom range
|
||||
for the agenda — or simply typing a number into that field — closed the app on
|
||||
any phone set to French. The French translation of the "%d days" label was
|
||||
missing the wording French uses for counts of two and above, and Android has
|
||||
nothing to fall back on when that particular form is absent, so every value
|
||||
except one day was affected ([#297], [#298]).
|
||||
|
||||
## [2.20.2] — 2026-09-12
|
||||
|
||||
### Fixed
|
||||
- **Crash reports are filed in English.** Reporting a crash from a phone set to
|
||||
another language prefilled the issue — title and body — in that language, so
|
||||
a French crash arrived on Codeberg as a French issue ([#297], [#298]). The
|
||||
text that is *posted* is now English for everyone; the dialog that asks you
|
||||
to send it, and everything else you see in the app, is still translated.
|
||||
|
||||
## [2.20.1] — 2026-09-07
|
||||
|
||||
### Fixed
|
||||
- **An empty calendar now explains itself.** "No calendars configured" offered
|
||||
an "Open system calendar settings" button that opened nothing — every screen
|
||||
wired it to the same reload that had just produced the empty state. It now
|
||||
opens the system's account chooser, filtered to the providers that sync
|
||||
calendars ([#239]).
|
||||
- **Calendars that are all switched off no longer look like an empty month.**
|
||||
When every calendar on the device is hidden at system level there is nothing
|
||||
to draw, but the app said nothing at all: it only ever checked whether the
|
||||
device had *no* calendars, so a device that had them and hid them fell
|
||||
through to a blank grid with no hint. That case now says so, and points at
|
||||
Settings → Calendars — the one screen that can switch a calendar back on. The
|
||||
navigation drawer's calendar list drew the same conflation and is fixed with
|
||||
it ([#239]).
|
||||
|
||||
## [2.20.0] — 2026-09-07
|
||||
|
||||
### Added
|
||||
- **Long-press to copy.** Holding down the title, the location or the
|
||||
description on an event puts that whole field on the clipboard. Tapping still
|
||||
does what it always did — the location opens maps, a link in the description
|
||||
opens — and the times, calendar, repeat rule and guests stay uncopyable. Below
|
||||
Android 13 a short confirmation appears; from Android 13 on the system shows
|
||||
its own clipboard chip ([#195]).
|
||||
- **The open-source projects Calendula ships** are now listed under Settings →
|
||||
About, each with its copyright holder and licence, and each row tapping
|
||||
through to that project's source ([#281]).
|
||||
|
||||
### Changed
|
||||
- Event titles no longer end in an ellipsis. The "…" took the width of a couple
|
||||
of characters and told you nothing you couldn't already see, so the title now
|
||||
simply runs to the edge of its chip in the month, week and day views — a few
|
||||
more letters per event, which is often the difference between two events you
|
||||
can tell apart and two you can't. Right-to-left languages keep the ellipsis,
|
||||
where clipping would cut the beginning of a title rather than its end
|
||||
([#164]).
|
||||
- Month-view events now show their start time before the title, where the chip
|
||||
is wide enough to keep the title readable — in landscape, on a foldable and on
|
||||
a tablet, and never at the cost of a narrow phone column. All-day events show
|
||||
no time, and neither does a bar carried in from the previous week, whose start
|
||||
is not in that row. Event times across the month, week and day views are also
|
||||
set in a regular weight against the title's, so a time reads as a time rather
|
||||
than as part of the name next to it ([#219]).
|
||||
- **The hour lines in week and day view are now an hour grid.** The setting
|
||||
that drew a faint separator line at each hour now seats every hour in its own
|
||||
rounded cell, so an hour boundary reads as the seam between two surfaces
|
||||
rather than a line drawn across one — the same negative space that separates
|
||||
the month grid's days and the week's columns. An event that starts on the hour
|
||||
fills its cell instead of overhanging the seam, and one that runs past
|
||||
midnight still meets the edge of its column ([#113]).
|
||||
- **An invitation you haven't answered now reads as an outline.** An event still
|
||||
waiting on your reply is drawn with the calendar's colour on its border and
|
||||
its title and nothing behind it, so it stands apart from the ones you have
|
||||
accepted — on the month chips, the all-day bars and the week and day blocks
|
||||
alike. Declined events keep their filled, struck-through chip and now sort
|
||||
last in the month view as well, so a day with more events than fit drops the
|
||||
ones you said no to first ([#230]).
|
||||
- The calendar titles now shorten instead of being cut off. When the full month
|
||||
name doesn't fit the top bar, the month and week views fall back to its
|
||||
three-letter form and the day view drops the weekday, rather than trailing off
|
||||
mid-word at large font sizes. The view switcher, the title and the agenda's
|
||||
range bar also line up with the grid underneath them ([#165]).
|
||||
- The jump-to-today button in the toolbar now shows today's date. It drew a
|
||||
generic calendar icon, which told you nothing you didn't already know from
|
||||
tapping it; it now carries the current day number in an outlined box, so the
|
||||
bar says what day it is as well as taking you there. It rolls over at midnight
|
||||
on its own ([#220]).
|
||||
- The quick-switch pill disappears when there is nothing left to switch between,
|
||||
and you can now turn calendar views off all the way down to a single one — the
|
||||
setting used to hold two open purely to keep the pill fed ([#150]).
|
||||
|
||||
### Fixed
|
||||
- **A long location no longer runs off the edge of its field.** The location on
|
||||
the edit screen sat on one line, so a full postal address or a long meeting
|
||||
link scrolled sideways out of sight as you typed it. It now wraps and the card
|
||||
grows to fit, with the pin and the contacts button staying level with the first
|
||||
line at any font size. A multi-line address you paste in is joined with commas
|
||||
— the way one picked from your contacts always has been — instead of running
|
||||
together into a single word ([#273]).
|
||||
- **The event form capitalizes sentences again.** The title, description and
|
||||
guest fields had stopped starting a sentence with a capital letter; they
|
||||
follow the keyboard's usual behaviour once more. The location is deliberately
|
||||
left alone, since it is as often a URL as an address ([#146]).
|
||||
- **A block you pick up no longer loses its second line.** Dragging an event
|
||||
whose time or title wrapped to two lines clipped the floating copy to one line
|
||||
for as long as you held it, and it snapped back on drop. The copy now measures
|
||||
its text exactly as the block does ([#267]).
|
||||
|
||||
## [2.19.4] — 2026-09-01
|
||||
|
||||
### Fixed
|
||||
- **A deleted occurrence no longer comes back when the series is re-timed.**
|
||||
Delete a single occurrence of a repeating event, then change the time of the
|
||||
whole series, and the occurrence you had removed reappeared. Doing it the
|
||||
other way round — "This and all following events" — lost every removal in the
|
||||
part of the series being changed. Removals now travel with the event: they
|
||||
keep their place in the series across a time change, a move to a different
|
||||
day, a timezone change and a switch to or from an all-day event, and they
|
||||
carry over when a series is split. Repeating events on your device's own
|
||||
calendars are affected, including the birthday and anniversary calendars
|
||||
Calendula creates from your contacts ([#248]).
|
||||
- **Home-screen widgets now turn the page at midnight.** Both the month and the
|
||||
agenda widget kept highlighting yesterday as "today" — and the agenda kept
|
||||
greying out the wrong events as already past — until you paged the month back
|
||||
and forth or removed and re-added the widget. Calendula now wakes itself at the
|
||||
day boundary and redraws, and re-arms after a reboot, a clock change or a
|
||||
flight into another timezone. Paging the month widget forward and back also
|
||||
stops quietly pinning it to that month, so it follows the date again instead of
|
||||
being stranded on the month you happened to be looking at ([#228]).
|
||||
- **"Only this event" now actually saves your edit.** On some calendars —
|
||||
including Google ones that still show as on-device, and any local calendar —
|
||||
editing a single occurrence of a repeating event did nothing at all: the scope
|
||||
dialog closed, the edit screen stayed put, and saving again just repeated it.
|
||||
Android can only attach a single-occurrence change to its series once the
|
||||
calendar has been synced at least once, so on those calendars the change had
|
||||
nowhere to go. Calendula now removes that one occurrence from the series and
|
||||
saves the edit as its own event instead, which is what you see either way. A
|
||||
save that does fail also says so for longer, rather than flashing past
|
||||
([#234]).
|
||||
- **A failed import no longer costs you the whole file.** One event the calendar
|
||||
refuses used to abort the entire import with nothing on screen but "couldn't
|
||||
read this file". Events are now added one at a time: anything rejected is
|
||||
counted and shown, and the rest still arrive. A faulty repeat rule in the file
|
||||
is repaired instead of being handed on ([#225]).
|
||||
- **All-day reminders survive a backup and an import.** They fire at the hour you
|
||||
chose, not at midnight UTC — the same rule the app already applied to all-day
|
||||
events you create yourself — and an export no longer leaves them out of the
|
||||
file entirely. An imported yearly birthday fires at the right hour too, even
|
||||
though the file dates it back to the year of birth ([#225]).
|
||||
- **Birthdays and anniversaries imported from Fossify Calendar now show up.**
|
||||
Fossify writes the ones it mirrors from your contacts as events that start and
|
||||
end on the same day, which the calendar read as lasting no time at all: they
|
||||
imported without complaint and then appeared nowhere. Any all-day event that
|
||||
ends where it starts, or carries no end at all, is now a one-day event
|
||||
([#225]).
|
||||
- **A backup no longer brings back occurrences you had deleted.** Remove a single
|
||||
occurrence of a repeating event, back up, restore — and it was there again,
|
||||
because the removals never made it into the file. They travel with the series
|
||||
now, so a restored calendar looks the way you left it ([#225]).
|
||||
- **A reminder left on your account's default no longer went off as the event
|
||||
started.** An event whose reminder says "Default" is the account's to time, not
|
||||
Calendula's — it was reading that setting as a lead time of its own and firing a
|
||||
minute after the event began. It now leaves such a reminder alone, and says so
|
||||
on the event.
|
||||
- **"Only this event" on an event that doesn't repeat now edits or deletes it.**
|
||||
Deleting used to write an exclusion onto a one-off event instead — which left
|
||||
the event there and lost its end time — and editing filed the change away as an
|
||||
exception to nothing, so it vanished.
|
||||
- **An import that the calendar cuts short now tells you how far it got.** Losing
|
||||
the calendar or the permission part way through a big file reported the whole
|
||||
import as failed, so importing again added everything twice.
|
||||
- **An event that runs past midnight can be dragged by either half.** Its second
|
||||
half couldn't be picked up at all, and the first could only be moved later. Both
|
||||
halves now move, and both are previewed while you hold either one, so the event
|
||||
reads as moving rather than growing. The blocks are cut square at midnight
|
||||
instead of rounded off, a month row draws an event crossing into the next week
|
||||
as one bar, and a move across a daylight-saving change no longer lands an hour
|
||||
out ([#253]).
|
||||
|
||||
### Added
|
||||
- **More of an imported `.ics` survives the trip**: tasks come across as events
|
||||
rather than being dropped in silence, deleted occurrences of a repeating event
|
||||
stay deleted, a calendar named like the one the file came from is preselected,
|
||||
and event colours are carried over — matched to the closest colour your
|
||||
calendar's account offers, so a migrated calendar still reads at a glance
|
||||
([#225]).
|
||||
- **Belarusian, Hungarian and Slovak**, all three contributed as community
|
||||
translations through
|
||||
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||
Belarusian covers about nine tenths of the app, Slovak about a third and
|
||||
Hungarian is just under way, so untranslated parts still show in English until
|
||||
they fill out. Pick any of them under Settings → Language or in Android's
|
||||
per-app language settings. Thanks to Dmitri Gabinski (Belarusian), Ábel Nagy
|
||||
(Hungarian) and Julie (Slovak); help finishing them is very welcome.
|
||||
|
||||
### Changed
|
||||
- **Arabic, Polish and Simplified Chinese are now complete**, all three carried
|
||||
the rest of the way this cycle — Simplified Chinese from a fifth of the app to
|
||||
all of it. Czech and Russian grew too — Russian to about half — and still have
|
||||
room. Thanks to Sup99999 (Arabic), Bazyli Cyran (Polish), JeanWenzel
|
||||
(Simplified Chinese), James Trivette (Czech), and SpiritOTHawk and Artur
|
||||
(Russian).
|
||||
|
||||
## [2.19.3] — 2026-08-22
|
||||
|
||||
### Added
|
||||
- **Czech, Japanese and Russian**, all three contributed as community
|
||||
translations through
|
||||
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||
They are early — Czech covers about a third of the app, Russian a fifth,
|
||||
Japanese has just started — so untranslated parts still show in English until
|
||||
they fill out. Pick any of them under Settings → Language or in Android's
|
||||
per-app language settings. Thanks to James Trivette (Czech), ame0011
|
||||
(Japanese), and chip and Pablo (Russian); help finishing them is very welcome.
|
||||
|
||||
### Changed
|
||||
- **Brazilian Portuguese is now complete**, covering the whole app. Thanks to
|
||||
bkrz for carrying it the rest of the way.
|
||||
|
||||
## [2.19.2] — 2026-08-17
|
||||
|
||||
### Changed
|
||||
- **A meeting you declined is now struck through** wherever it appears — month,
|
||||
week, day, agenda, search and both widgets — and no longer schedules a
|
||||
reminder. Declining an invitation in Google Calendar left the event looking
|
||||
like any other in Calendula, and it still notified you about a meeting you had
|
||||
said no to. It stays visible rather than disappearing: the organiser still
|
||||
expects an answer from you, and the slot is still spoken for ([#180]).
|
||||
|
||||
### Fixed
|
||||
- **Tapping an event in month view opens the event**, not the day it sits on.
|
||||
Every month style is affected — page, rolling, seamless weeks — and until now
|
||||
the only way to reach an event from the month was to open its day first and
|
||||
find it again there. Tapping anywhere else in the cell still opens the day
|
||||
([#187]).
|
||||
- **An edit made to an event now shows the moment you re-open it.** Adding a
|
||||
description to an event and opening it again showed the sheet as it was before
|
||||
the save, because the detail was only re-read when a different occurrence was
|
||||
opened ([#196]).
|
||||
- **The month widget's arrows stopped working after a couple of taps.** The grid
|
||||
was serialised as roughly 740 views, and one update ran to half a megabyte —
|
||||
more than the launcher's buffer takes. The third update overran it, and Android
|
||||
responded by dropping the whole widget host, which killed updates for *every*
|
||||
widget on the home screen, ours and other apps', until the launcher rebound.
|
||||
The grid now draws 192 views, and a resized widget reflows to its new size
|
||||
instead of clipping ([#214]).
|
||||
- Tapping a widget's refresh or month arrows redraws that widget by its own id
|
||||
instead of asking Android to update all of them, which does nothing in a
|
||||
process the tap has just woken from cold ([#18]).
|
||||
- **Jump-to-today in seamless weeks lands on the current week** instead of
|
||||
leaving a sliver of the previous row on screen ([#191]).
|
||||
- Day and week view no longer run their events flush against the right edge
|
||||
([#192]).
|
||||
|
||||
## [2.19.1] — 2026-08-11
|
||||
|
||||
### Added
|
||||
- **Drag to reschedule can be turned off.** Settings → Views → *Drag to
|
||||
reschedule*, on by default. Off, no event can be picked up in the month, week
|
||||
or day view — for anyone who kept moving appointments by accident while
|
||||
scrolling ([#173]).
|
||||
- An early Brazilian Portuguese translation. Calendula has started speaking
|
||||
Brazilian Portuguese, contributed as a community translation through
|
||||
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||
About a third of the app is covered, so the rest still shows in English — you
|
||||
can pick it under Settings → Language or in Android's per-app language
|
||||
settings. Thanks to
|
||||
[bkrz](https://weblate.dev.jeanlucmakiola.de/user/bkrz/) for getting it
|
||||
started; help finishing it is very welcome.
|
||||
|
||||
### Fixed
|
||||
- **The language picker forgot which language was picked.** Android hands the
|
||||
applied language back with its script filled in — Simplified Chinese goes in
|
||||
as `zh-CN` and comes out as `zh-Hans-CN` — so after a restart no entry in
|
||||
Settings → Language showed as selected, and the row above it named the
|
||||
language the long way round. Only languages that imply a script were
|
||||
affected.
|
||||
|
||||
## [2.19.0] — 2026-08-10
|
||||
|
||||
### Added
|
||||
- **Drag an event to reschedule it.** Press and hold an event in the month,
|
||||
week or day view and drop it on another time or day — the only way to move an
|
||||
appointment used to be opening it and editing its start and end by hand. The
|
||||
block follows your finger, the timeline scrolls when you carry it to an edge,
|
||||
and the event's length is preserved: a 90-minute meeting dropped on 09:00 ends
|
||||
at 10:30. In week and day view the drop snaps to 15-minute steps; in month
|
||||
view it moves the event to the day you dropped it on and leaves the time
|
||||
alone. A confirmation names the new slot and offers **Undo** wherever the move
|
||||
can be taken back in one step. Events you can't edit — read-only calendars, and
|
||||
birthdays and anniversaries mirrored from your contacts — can't be picked up.
|
||||
Rescheduling isn't pointer-only: a screen reader gets a **Move…** action on
|
||||
every event that can be moved ([#68]).
|
||||
- Dragging a **recurring** event asks what it should apply to, the same *this
|
||||
event / this and following / all events* choice a save does. Where moving the
|
||||
whole series would change which weekday it falls on, the rule is rewritten to
|
||||
match — a weekly Monday event dropped on a Wednesday becomes a weekly
|
||||
Wednesday event. Where it can't be rewritten safely, the prompt says so and
|
||||
offers to move just the one occurrence, rather than quietly leaving the
|
||||
series behind ([#68]).
|
||||
- **Delete several search results at once.** Long-press a hit in search to enter
|
||||
selection mode, tap the rest (or **Select all**), and delete the lot in one
|
||||
action — finding and removing, say, every leftover event with the same title
|
||||
no longer means opening them one at a time. Recurring hits in the batch ask
|
||||
once for the whole selection instead of once each, results on read-only
|
||||
calendars can't be selected, and the deletion is confirmed before it runs
|
||||
([#80]).
|
||||
|
||||
### Changed
|
||||
- **First launch is now a short wizard** instead of a single permission screen.
|
||||
It counts its steps, each one can be stepped back out of, and beyond granting
|
||||
calendar access it offers two things worth deciding once: turning on automatic
|
||||
backup — chosen inline, folder picker and all, since events that live only on
|
||||
the phone are lost with it — and choosing which view Calendula opens on, with
|
||||
a live preview of the month, week, day and agenda layouts rather than four
|
||||
names in a list. Both are skippable, everything chosen is changeable in
|
||||
Settings afterwards, and the backup step is offered only where nothing
|
||||
writable is being synced anywhere. Existing installs are not re-onboarded —
|
||||
revoking and re-granting the calendar permission later does not restart the
|
||||
wizard ([#163]).
|
||||
- The one-time notice about calendars switched off for this device is now a step
|
||||
in the flow rather than a dialog thrown over the app on first open.
|
||||
|
||||
### Fixed
|
||||
- Removed the deprecated `statusBarColor` / `navigationBarColor` theme
|
||||
attributes the Play Console flags on Android 15 and up, where they no longer
|
||||
do anything. The launch backdrop now picks its status-bar icons from the
|
||||
light/dark resource instead, so they stay legible over it either way
|
||||
([#123]).
|
||||
|
||||
## [2.18.1] — 2026-08-06
|
||||
|
||||
### Fixed
|
||||
- Tapping an empty slot in week or day view creates the event at the hour you
|
||||
tapped, after the timeline has been zoomed. Pinching the day taller or shorter
|
||||
— or changing **Hour height** in Settings — left the tap still being measured
|
||||
against the old spacing, so the new event landed at some other hour entirely
|
||||
([#148]).
|
||||
|
||||
## [2.18.0] — 2026-07-31
|
||||
|
||||
### Added
|
||||
- A new event no longer always lasts an hour. **Settings → New event form →
|
||||
Default duration** sets how long one opens, and each calendar may keep its own
|
||||
length underneath — 8 hours for the calendar you keep work shifts in, 30
|
||||
minutes for the one you book calls in. A calendar without its own length
|
||||
follows the default, switching calendars mid-form re-stretches the event, and
|
||||
setting an end time by hand keeps it. An event another app hands over with only
|
||||
a start gets the default too; one that names its own end — an `.ics` file, a
|
||||
duplicate — keeps that length. All-day events are unaffected ([#54]).
|
||||
- Week and day view can be set to show more or less of the day at once, under
|
||||
Settings → Views → Week & day → **Hour height**. **Fit whole day** sizes an
|
||||
hour to your screen so all 24 hours are visible without scrolling — on a tall
|
||||
phone the old fixed spacing showed only about half a day, so appointments
|
||||
could sit below the fold all week. Compact and Comfortable are fixed steps
|
||||
either side of the previous spacing, which stays the default. Both views share
|
||||
the setting ([#56]).
|
||||
- Week and day view can be **pinched** with two fingers to set the hour height
|
||||
directly, anywhere between and beyond the named steps. The time under your
|
||||
fingers stays put as it zooms, so you keep your place in the day. A pinched
|
||||
height is remembered and appears as **Custom** in the Hour height setting, so
|
||||
tapping a named step there takes you back to it ([#56]).
|
||||
|
||||
### Changed
|
||||
- The date in the top bar is now the way to jump: tapping the month, week or day
|
||||
title opens the same date picker the sidebar's **Jump to date** offers, seeded
|
||||
on whatever the bar is naming. A drop-down caret marks it as tappable. The
|
||||
sidebar entry stays where it is ([#57]).
|
||||
- A reminder in the status bar is now Calendula's own mark — the calendar with
|
||||
the bloom — instead of the generic calendar glyph it shared with the system's
|
||||
date surfaces and every other calendar app. The status bar draws that icon as
|
||||
a plain silhouette, so its shape is the only thing that could tell them apart
|
||||
([#83]).
|
||||
- Event blocks now only draw text they can draw whole. One too short for a full
|
||||
line shows no title rather than a sliced one, a block that cannot fit both its
|
||||
title and its time keeps the title, and a block too narrow to hold more than a
|
||||
syllable stays on one ellipsised line instead of stacking letters down the
|
||||
block. Tapping and the spoken description are unchanged ([#56]).
|
||||
- Calendar colours are reworked so text on an event is always readable. They
|
||||
were shaped by a brightness setting that does not match what the eye sees, so
|
||||
whether a block got dark or light text depended on which hue you happened to
|
||||
pick — an orange calendar took dark text while a red one beside it took light
|
||||
— and colours landing in between were hard to read either way. Each colour now
|
||||
keeps its hue and is moved clear of that middle: most become deep blocks with
|
||||
light text, while naturally pale colours such as yellow stay pale and take
|
||||
dark text, rather than being forced into a muddy brown. Dots, stripes and
|
||||
icons are tuned separately from blocks, so they stay visible against the
|
||||
background instead of sharing a colour meant to sit behind text. The setting
|
||||
is now called **Harmonise calendar colours**; turning it off still shows the
|
||||
raw colours from your calendar source ([#21], [#36]).
|
||||
|
||||
### Fixed
|
||||
- The back gesture closes the sidebar instead of the app. With the drawer open,
|
||||
swiping back left Calendula altogether rather than putting the sidebar away
|
||||
([#114]).
|
||||
- The sidebar lines up. "Calendula", "View" and "Calendars" now share the left
|
||||
edge of the rows under them, and the view, jump-to-date, Settings and calendar
|
||||
rows sit on one vertical axis instead of each icon finding its own — the same
|
||||
alignment the Settings screens use ([#114]).
|
||||
- The status- and navigation-bar icons follow Calendula's own light/dark choice.
|
||||
Setting the app dark while the system stayed light — or the other way round —
|
||||
left the clock and battery drawn for the system's theme, so they could sit
|
||||
near-invisible against the app's own bar ([#70]).
|
||||
|
||||
## [2.17.1] — 2026-07-30
|
||||
|
||||
### 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
|
||||
@@ -1119,3 +1757,87 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#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
|
||||
[#83]: https://codeberg.org/jlmakiola/calendula/issues/83
|
||||
[#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
|
||||
[#54]: https://codeberg.org/jlmakiola/calendula/issues/54
|
||||
[#57]: https://codeberg.org/jlmakiola/calendula/issues/57
|
||||
[#56]: https://codeberg.org/jlmakiola/calendula/issues/56
|
||||
[#114]: https://codeberg.org/jlmakiola/calendula/issues/114
|
||||
[#148]: https://codeberg.org/jlmakiola/calendula/issues/148
|
||||
[#68]: https://codeberg.org/jlmakiola/calendula/issues/68
|
||||
[#80]: https://codeberg.org/jlmakiola/calendula/issues/80
|
||||
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
||||
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
||||
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
||||
[#164]: https://codeberg.org/jlmakiola/calendula/issues/164
|
||||
[#165]: https://codeberg.org/jlmakiola/calendula/issues/165
|
||||
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
|
||||
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
|
||||
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
|
||||
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
|
||||
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
||||
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
||||
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225
|
||||
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
|
||||
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
|
||||
[#219]: https://codeberg.org/jlmakiola/calendula/issues/219
|
||||
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
|
||||
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253
|
||||
[#273]: https://codeberg.org/jlmakiola/calendula/issues/273
|
||||
[#113]: https://codeberg.org/jlmakiola/calendula/issues/113
|
||||
[#220]: https://codeberg.org/jlmakiola/calendula/issues/220
|
||||
[#195]: https://codeberg.org/jlmakiola/calendula/issues/195
|
||||
[#281]: https://codeberg.org/jlmakiola/calendula/issues/281
|
||||
[#230]: https://codeberg.org/jlmakiola/calendula/issues/230
|
||||
[#150]: https://codeberg.org/jlmakiola/calendula/issues/150
|
||||
[#146]: https://codeberg.org/jlmakiola/calendula/issues/146
|
||||
[#267]: https://codeberg.org/jlmakiola/calendula/issues/267
|
||||
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
|
||||
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297
|
||||
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298
|
||||
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309
|
||||
[#305]: https://codeberg.org/jlmakiola/calendula/issues/305
|
||||
[#294]: https://codeberg.org/jlmakiola/calendula/issues/294
|
||||
[#287]: https://codeberg.org/jlmakiola/calendula/issues/287
|
||||
[#189]: https://codeberg.org/jlmakiola/calendula/issues/189
|
||||
[#190]: https://codeberg.org/jlmakiola/calendula/issues/190
|
||||
[#212]: https://codeberg.org/jlmakiola/calendula/issues/212
|
||||
[#213]: https://codeberg.org/jlmakiola/calendula/issues/213
|
||||
[#162]: https://codeberg.org/jlmakiola/calendula/issues/162
|
||||
[#289]: https://codeberg.org/jlmakiola/calendula/issues/289
|
||||
[#290]: https://codeberg.org/jlmakiola/calendula/issues/290
|
||||
[#304]: https://codeberg.org/jlmakiola/calendula/issues/304
|
||||
[#321]: https://codeberg.org/jlmakiola/calendula/issues/321
|
||||
[#329]: https://codeberg.org/jlmakiola/calendula/issues/329
|
||||
[#285]: https://codeberg.org/jlmakiola/calendula/issues/285
|
||||
[#338]: https://codeberg.org/jlmakiola/calendula/issues/338
|
||||
[#111]: https://codeberg.org/jlmakiola/calendula/issues/111
|
||||
[#131]: https://codeberg.org/jlmakiola/calendula/issues/131
|
||||
[#142]: https://codeberg.org/jlmakiola/calendula/issues/142
|
||||
[#184]: https://codeberg.org/jlmakiola/calendula/issues/184
|
||||
[#185]: https://codeberg.org/jlmakiola/calendula/issues/185
|
||||
[#188]: https://codeberg.org/jlmakiola/calendula/issues/188
|
||||
[#193]: https://codeberg.org/jlmakiola/calendula/issues/193
|
||||
[#197]: https://codeberg.org/jlmakiola/calendula/issues/197
|
||||
[#241]: https://codeberg.org/jlmakiola/calendula/issues/241
|
||||
[#251]: https://codeberg.org/jlmakiola/calendula/issues/251
|
||||
[#291]: https://codeberg.org/jlmakiola/calendula/issues/291
|
||||
[#308]: https://codeberg.org/jlmakiola/calendula/issues/308
|
||||
[#322]: https://codeberg.org/jlmakiola/calendula/issues/322
|
||||
[#331]: https://codeberg.org/jlmakiola/calendula/issues/331
|
||||
[#336]: https://codeberg.org/jlmakiola/calendula/issues/336
|
||||
[#357]: https://codeberg.org/jlmakiola/calendula/issues/357
|
||||
[#359]: https://codeberg.org/jlmakiola/calendula/issues/359
|
||||
[#361]: https://codeberg.org/jlmakiola/calendula/issues/361
|
||||
[#363]: https://codeberg.org/jlmakiola/calendula/issues/363
|
||||
[#364]: https://codeberg.org/jlmakiola/calendula/issues/364
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
# Contributing to Calendula
|
||||
|
||||
Calendula is a Material 3 Expressive calendar app that lives strictly on top of
|
||||
Android's `CalendarContract`, with no app database, sync stack or network access.
|
||||
That constraint shapes most review comments, so skim
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) before you write code. This file
|
||||
covers the practical side.
|
||||
|
||||
[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home for
|
||||
issues, pull requests and 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 | Use [Weblate](#translations), not a pull request |
|
||||
|
||||
Features get an opinion before they get code: whether Calendula should do a
|
||||
thing at all is the one decision a patch can't make. A feature PR that arrives
|
||||
without a discussed issue may be closed unmerged even when the code is good, so
|
||||
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, and no calendar content or 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, and 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 and 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 opens a prefilled web issue
|
||||
on purpose instead of posting anything itself.
|
||||
2. **The provider is the only database.** No Room, no cache, no local mirror of
|
||||
events. `CalendarContract` is the single source of truth, which is also why
|
||||
externally synced changes work for free.
|
||||
3. **Don't patch UI state after a write.** A `ContentObserver` re-queries and
|
||||
views recompose from fresh provider state. Hand-patching a list after saving
|
||||
appears to work, then drifts away from what the provider stored.
|
||||
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.
|
||||
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, because 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 and 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. Code changes don't go
|
||||
there. 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.
|
||||
@@ -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"
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
<h1>Calendula</h1>
|
||||
|
||||
<p><strong>A modern Material 3 Expressive calendar for Android.</strong><br>
|
||||
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
|
||||
<p><strong>A Material 3 Expressive calendar for Android.</strong><br>
|
||||
It works on top of the system calendar and has no network access.</p>
|
||||
|
||||
<p>
|
||||
<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>
|
||||
@@ -18,74 +18,98 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
|
||||
<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://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" 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>
|
||||
</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/02-month.png" width="19%" 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/05-edit.png" width="19%" alt="Event form">
|
||||
<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/01-week.png" width="13%" alt="Week view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="13%" alt="Month view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-agenda.png" width="13%" alt="Agenda view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="13%" alt="Event detail">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-recurring.png" width="13%" alt="Editing a recurring event">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-calendars.png" width="13%" alt="Calendars from your accounts">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/07-dark.png" width="13%" alt="Dark theme">
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
Calendula is named after the flower whose name — like the word *calendar* —
|
||||
comes from the Latin *kalendae*, the first day of the month. It lives
|
||||
entirely on top of Android's `CalendarContract`: any calendar synced to your
|
||||
device (CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply
|
||||
appears, and everything you create or edit syncs back the same way. No own
|
||||
database, no sync stack reinvented.
|
||||
Calendula is named after the flower. Both its name and the word *calendar*
|
||||
come from the Latin *kalendae*, the first day of the month.
|
||||
|
||||
## ✨ Features
|
||||
The app works directly on Android's `CalendarContract`. Any calendar synced to
|
||||
your device (CalDAV via DAVx5, Google, local, WebCal subscriptions) shows up in
|
||||
Calendula, and whatever you create or edit syncs back the same way. Calendula
|
||||
has no database or sync code of its own.
|
||||
|
||||
**Calendar**
|
||||
## Features
|
||||
|
||||
- Month, week, and day views with a one-tap view switcher
|
||||
- Full event details — attendees and their responses, reminders, recurrence
|
||||
(humanized), availability, visibility, foreign time zones
|
||||
- Per-calendar visibility toggle, grouped by account
|
||||
### Calendar
|
||||
|
||||
**Editing**
|
||||
- Month, week and day views, with a switcher in the top bar
|
||||
- Event details with attendees and their responses, reminders, recurrence in
|
||||
plain words, availability, visibility and foreign time zones
|
||||
- Calendars can be shown or hidden one by one, grouped by account
|
||||
- Your contacts' birthdays, anniversaries and other dates, if you turn it on.
|
||||
Contacts are read on the device and never changed.
|
||||
|
||||
- Create, edit, and delete events — including recurring events with scoped
|
||||
writes: *only this event*, *this and all following*, or *the whole series*
|
||||
- Recurrence picker with one-tap presets and custom rules (interval, weekday
|
||||
toggles, end conditions); rules it can't express are preserved verbatim
|
||||
- Conflict-safe saves: if an event changed elsewhere while you were editing,
|
||||
Calendula asks instead of silently overwriting
|
||||
- Read-only calendars (WebCal, birthdays) are detected and respected
|
||||
### Editing
|
||||
|
||||
**Reminders**
|
||||
- Create, edit and delete events. For a recurring event you choose whether a
|
||||
change applies to *only this event*, *this and all following*, or *the whole
|
||||
series*.
|
||||
- The repeat picker has presets and custom rules (interval, weekdays, end
|
||||
condition). Rules it can't show are kept as they are.
|
||||
- If an event changed elsewhere while you were editing it, Calendula asks
|
||||
before saving over it.
|
||||
- Read-only calendars such as WebCal subscriptions and birthdays are detected
|
||||
and can't be edited by accident.
|
||||
|
||||
- Event reminders delivered by Calendula itself as notifications —
|
||||
essential when it's your only calendar app, since Android delegates
|
||||
reminder delivery to calendar apps
|
||||
- Tap a reminder to land on the event
|
||||
### Reminders
|
||||
|
||||
**Design & privacy**
|
||||
- Calendula shows event reminders as notifications itself. Android leaves
|
||||
reminder delivery to calendar apps, so if Calendula is your only one, this is
|
||||
where your reminders come from.
|
||||
- Tapping a reminder opens the event.
|
||||
|
||||
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
|
||||
expressive motion and shapes, light/dark theme
|
||||
- German and English UI, per-app language setting — and [open to community
|
||||
translations](#-translations)
|
||||
- **Zero telemetry, zero analytics, no internet permission** — your data
|
||||
never leaves the device
|
||||
### Design and privacy
|
||||
|
||||
## 📦 Install
|
||||
- Material 3 Expressive throughout, with dynamic color on Android 12+, light
|
||||
and dark themes, and expressive motion and shapes
|
||||
- Available in 16 languages, with a per-app language setting.
|
||||
[Help translate](#translations).
|
||||
- No telemetry, no analytics and no internet permission. Your data stays on
|
||||
the device.
|
||||
|
||||
## Install
|
||||
|
||||
All channels install the same app:
|
||||
|
||||
| Channel | Updates | Notes |
|
||||
| --- | --- | --- |
|
||||
| [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup |
|
||||
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; add the repo once |
|
||||
| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automatic with Obtainium |
|
||||
| [Google Play](#google-play) | Per release | Signed with Google's key, see below |
|
||||
| [Build from source](#build-from-source) | Whenever you build | Full control |
|
||||
|
||||
### F-Droid (recommended)
|
||||
|
||||
Calendula is on the **official [F-Droid](https://f-droid.org) repository** —
|
||||
just search for **Calendula** in any F-Droid client, or
|
||||
Calendula is in the official [F-Droid](https://f-droid.org) repository. Search
|
||||
for Calendula in any F-Droid client, or
|
||||
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
|
||||
|
||||
### Self-hosted repo (latest builds)
|
||||
F-Droid builds 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
|
||||
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:
|
||||
### Self-hosted F-Droid repo (fastest updates)
|
||||
|
||||
The release pipeline builds, signs and publishes every release to a
|
||||
self-hosted F-Droid repository, so new versions land there first. Add it once
|
||||
and your F-Droid client handles updates from then on:
|
||||
|
||||
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
|
||||
link below on your phone):
|
||||
@@ -98,28 +122,87 @@ rebuilds on F-Droid's own schedule. Add it for the freshest builds:
|
||||
fingerprint (SHA-256):
|
||||
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
|
||||
|
||||
2. Refresh, search for **Calendula**, install.
|
||||
2. Refresh, search for Calendula and install it.
|
||||
|
||||
Both channels share the same signing key, so you can switch between them
|
||||
without reinstalling. Or build from source — see below.
|
||||
### Codeberg release / Obtainium
|
||||
|
||||
## 📚 Documentation
|
||||
Every release is also on
|
||||
[Codeberg](https://codeberg.org/jlmakiola/calendula/releases) with the signed
|
||||
APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum. Download it and install
|
||||
it directly.
|
||||
|
||||
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
|
||||
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
||||
- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next
|
||||
For automatic updates from Codeberg, use
|
||||
[Obtainium](https://github.com/ImranR98/Obtainium). On your phone,
|
||||
[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula),
|
||||
or add it by hand: *Add App*, paste `https://codeberg.org/jlmakiola/calendula`,
|
||||
then *Add*. Obtainium then watches the releases and tells you when a new one
|
||||
is out. Betas of upcoming versions are published there too, as pre-releases; to
|
||||
test them, switch on *Include prereleases* for Calendula in Obtainium.
|
||||
|
||||
## 🌍 Translations
|
||||
### Google Play
|
||||
|
||||
Calendula ships in German and English, and you're warmly invited to add your
|
||||
language. Translations are managed on a self-hosted **Weblate**:
|
||||
Calendula is on
|
||||
[Google Play](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula).
|
||||
|
||||
**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
|
||||
Play builds are signed with Google's key rather than mine, so switching between
|
||||
Play and any other channel means uninstalling first. You lose the app settings.
|
||||
Your events live in the system calendar and aren't affected.
|
||||
|
||||
No coding needed — register on the Weblate server, pick (or request) a language,
|
||||
and translate the strings in your browser. You can also reach this link in the
|
||||
app from the top of **Settings → App language**.
|
||||
### Build from source
|
||||
|
||||
## 📜 License
|
||||
It's a plain Gradle build with no proprietary dependencies. See
|
||||
[docs/BUILDING.md](docs/BUILDING.md), and note the `floret-kit` submodule.
|
||||
|
||||
[MIT](LICENSE) — Jean-Luc Makiola, 2026
|
||||
<sub>Official F-Droid, the self-hosted repo and the Codeberg releases share the
|
||||
same signing key, so you can switch between them without reinstalling. Google
|
||||
Play is the exception (see above).</sub>
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Contributing](CONTRIBUTING.md): how to report bugs, propose features and send patches
|
||||
- [Building from source](docs/BUILDING.md): requirements and Gradle tasks
|
||||
- [Architecture](docs/ARCHITECTURE.md): the layers and the main pipelines
|
||||
- [Milestones](https://codeberg.org/jlmakiola/calendula/milestones): what has shipped and what's next
|
||||
- [Privacy policy](docs/PRIVACY.md): the source of the page at
|
||||
[jeanlucmakiola.de/calendula/privacy](https://jeanlucmakiola.de/calendula/privacy)
|
||||
|
||||
## Contributing
|
||||
|
||||
Bug reports, ideas and patches are welcome on
|
||||
[Codeberg](https://codeberg.org/jlmakiola/calendula/issues).
|
||||
|
||||
Start with an issue. A feature gets a yes or no before anyone writes code, and
|
||||
features and bugs are assigned a milestone whose `release/vX.Y.Z` branch your
|
||||
pull request targets. Typo and docs fixes can go straight to a pull request.
|
||||
Translations go through [Weblate](#translations), not pull requests.
|
||||
|
||||
Read [CONTRIBUTING.md](CONTRIBUTING.md) before writing code. It covers the
|
||||
workflow, the build (including the `floret-kit` submodule) and the
|
||||
architectural rules changes are reviewed against.
|
||||
|
||||
## Translations
|
||||
|
||||
Calendula is written in English. Translators on Weblate have added Arabic,
|
||||
Belarusian, Chinese (Simplified), Czech, French, German, Hungarian, Italian,
|
||||
Japanese, Polish, Portuguese (Portugal and Brazil), Russian, Slovak and Spanish,
|
||||
some of them partly. Untranslated strings fall back to English, so a partial
|
||||
translation is fine. Translations are managed on a self-hosted Weblate:
|
||||
|
||||
[Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)
|
||||
|
||||
You don't need to code. Register on the Weblate server, pick a language (or
|
||||
request one) and translate in your browser. The app links here from the top of
|
||||
*Settings → App language*.
|
||||
|
||||
## Privacy
|
||||
|
||||
Calendula has no analytics, tracking, ads, crash-reporting SDK or third-party
|
||||
services. It doesn't have the internet permission, so it can't send anything
|
||||
anywhere. Your events stay in Android's own calendar store.
|
||||
|
||||
[Privacy policy](https://jeanlucmakiola.de/calendula/privacy), written in
|
||||
[`docs/PRIVACY.md`](docs/PRIVACY.md), which is what that page renders.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE), Jean-Luc Makiola, 2026
|
||||
|
||||
+18
-10
@@ -26,10 +26,14 @@ android {
|
||||
// These committed values ARE the source of truth for a release: merging
|
||||
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||
versionCode = 21600
|
||||
versionName = "2.16.0"
|
||||
// release itself. A versionName of X.Y.Z-beta.N pushed to a release/*
|
||||
// branch instead cuts a Codeberg-only pre-release (beta.yaml).
|
||||
// versionCode is derived from versionName by scripts/version_info.sh
|
||||
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
|
||||
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
|
||||
// See docs/RELEASING.md.
|
||||
versionCode = 2220401
|
||||
versionName = "2.22.4-beta.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -113,12 +117,16 @@ android {
|
||||
lint {
|
||||
// Community translations are expected to be partial — a missing string
|
||||
// falls back to the English base at runtime — so don't fail the build on
|
||||
// it. Likewise a translated <plurals> may not fill every CLDR quantity
|
||||
// form its locale defines (e.g. Arabic needs "zero"); the missing form
|
||||
// falls back to "other" at runtime, so MissingQuantity is informational
|
||||
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
|
||||
// check_translations.py guards the same invariants with clearer,
|
||||
// translator-facing messages.
|
||||
// it. A translated <plurals> may likewise skip a CLDR quantity form its
|
||||
// locale defines (e.g. Arabic "zero"): Android falls back to "other" for
|
||||
// any form it cannot find, so MissingQuantity is informational too.
|
||||
// What a translation must NOT skip is "other" itself — nothing falls back
|
||||
// for that one, not even the base locale, so it throws
|
||||
// Resources$NotFoundException at runtime (Codeberg #297/#298).
|
||||
// MissingQuantity doesn't tell the two cases apart, so that invariant is
|
||||
// enforced by scripts/check_translations.py instead, with a clearer
|
||||
// translator-facing message. Stale/extra keys (ExtraTranslation) stay
|
||||
// fatal.
|
||||
informational += listOf("MissingTranslation", "MissingQuantity")
|
||||
}
|
||||
|
||||
|
||||
Vendored
+18
@@ -38,3 +38,21 @@
|
||||
# SessionWorker never ran, and widgets were stuck on their loading layout
|
||||
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
|
||||
-keep class * extends androidx.work.InputMerger { <init>(...); }
|
||||
|
||||
# Glance identifies a widget by its GlanceAppWidget subclass's *canonical name*:
|
||||
# GlanceAppWidgetManager persists a providerName -> receivers map under that
|
||||
# string, and `updateAll` looks the widget's app-widget ids up through it. Under
|
||||
# R8 full mode (AGP 9 default) MonthWidget and AgendaWidget — same supertype,
|
||||
# same overrides, no distinguishing members — were horizontally merged into one
|
||||
# class, so both receivers registered under the *same* provider name and
|
||||
# `AgendaWidget().updateAll()` resolved the month widget's id too, redrawing a
|
||||
# placed month widget as the agenda one on the next data change (#89). Keeping
|
||||
# the real names also survives app updates, which would otherwise renumber the
|
||||
# obfuscated name and orphan the stored mapping.
|
||||
-keep class * extends androidx.glance.appwidget.GlanceAppWidget
|
||||
|
||||
# Belt and braces one level up: the two receivers are nearly as alike, and the
|
||||
# provider map is keyed off the receiver component too. Redundant today (AGP's
|
||||
# manifest-derived rules cover them), but #89 cost a release to diagnose and the
|
||||
# guarantee should not rest on a component staying in the manifest.
|
||||
-keep class * extends androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
@@ -33,6 +33,14 @@
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
|
||||
<!--
|
||||
A reboot clears every pending alarm, including the one holding the next
|
||||
reminder. Now that the app schedules that alarm itself (#75) rather than
|
||||
leaning on the provider's, it has to hear about the reboot to re-arm it —
|
||||
otherwise reminders simply stop after a restart.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||
returns null and the calendar manager's per-account "manage" button can't
|
||||
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
||||
@@ -270,17 +278,22 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
||||
no notification itself — a calendar app must (v1.4, Etar model).
|
||||
Exported: the broadcast arrives from the provider's process. -->
|
||||
<!-- Reminder delivery is the app's own (#75): it plans the alarms from
|
||||
Instances + Reminders instead of waiting for the provider's
|
||||
EVENT_REMINDER broadcast, which OEM-modified providers demonstrably
|
||||
retarget or never send. This receiver takes our scan alarm plus
|
||||
every outside event that invalidates it — boot and package-replace
|
||||
wipe pending alarms, and a clock or timezone change moves every
|
||||
reminder relative to the one that is armed.
|
||||
Exported: the system broadcasts arrive from outside the app. -->
|
||||
<receiver
|
||||
android:name=".data.reminders.EventReminderReceiver"
|
||||
android:name=".data.reminders.ReminderScheduleReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.EVENT_REMINDER" />
|
||||
<data
|
||||
android:host="com.android.calendar"
|
||||
android:scheme="content" />
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
<action android:name="android.intent.action.TIME_SET" />
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
@@ -317,9 +330,13 @@
|
||||
</receiver>
|
||||
|
||||
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
||||
PROVIDER_CHANGED on any data change (our writes and external sync),
|
||||
and the system broadcasts the date/time ones at midnight / clock
|
||||
changes so "today" highlighting rolls over. -->
|
||||
PROVIDER_CHANGED on any data change (our writes and external sync).
|
||||
The day boundary arrives as the app's own ROLLOVER alarm (#228), by
|
||||
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is
|
||||
a free extra only, since Android 8+ withholds it from manifest
|
||||
receivers. The four below re-arm that alarm: TIME_SET /
|
||||
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it.
|
||||
Exported: the system broadcasts arrive from outside the app. -->
|
||||
<receiver
|
||||
android:name=".widget.WidgetUpdateReceiver"
|
||||
android:exported="true">
|
||||
@@ -333,6 +350,8 @@
|
||||
<action android:name="android.intent.action.DATE_CHANGED" />
|
||||
<action android:name="android.intent.action.TIME_SET" />
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
|
||||
@@ -4,9 +4,13 @@ import android.app.Application
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -28,17 +32,63 @@ class CalendulaApp : Application() {
|
||||
// respecting, on-device; the user submits the report by hand). The
|
||||
// capture/loop-detection/report machinery lives in floret-kit's
|
||||
// core-crash; only the app label + issue-tracker URLs are app-specific.
|
||||
// The prefilled issue's own title and body are the kit's, in English:
|
||||
// they are posted to the tracker, so they don't follow the device
|
||||
// language the way the dialog around them does.
|
||||
CrashReporter.install(
|
||||
this,
|
||||
CrashConfig(
|
||||
appLabel = getString(R.string.app_name),
|
||||
newIssueUrl = getString(R.string.report_issue_url),
|
||||
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
||||
issueTitle = getString(R.string.crash_report_issue_title),
|
||||
),
|
||||
)
|
||||
reconcileAutoBackup()
|
||||
reconcileSpecialDates()
|
||||
reconcileCalendarVisibility()
|
||||
startReminderDelivery()
|
||||
reconcileWidgetRollover()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-arm the widgets' midnight rollover from whatever is actually placed
|
||||
* (#228). Idempotent, and it covers what no broadcast reaches — an alarm
|
||||
* dropped by a force-stop is armed again the next time the app is opened.
|
||||
* Off the main thread: a handful of binder calls on every process start.
|
||||
*/
|
||||
private fun reconcileWidgetRollover() {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
WidgetRolloverScheduler.sync(this@CalendulaApp)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +111,8 @@ class CalendulaApp : Application() {
|
||||
|
||||
/**
|
||||
* Re-arm (or cancel) the daily special-dates reconcile from the saved
|
||||
* settings on every launch — like [reconcileAutoBackup], this cancels
|
||||
* orphaned work after the feature is turned off and re-schedules after a
|
||||
* reinstall. The foreground trigger (RootScreen ON_RESUME) does the on-open
|
||||
* refresh, so no immediate run is needed here.
|
||||
* settings, like [reconcileAutoBackup]. The on-open refresh is RootScreen's
|
||||
* ON_RESUME trigger, so no immediate run is needed here.
|
||||
*/
|
||||
private fun reconcileSpecialDates() {
|
||||
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)
|
||||
|
||||
@@ -2,9 +2,11 @@ package de.jeanlucmakiola.calendula
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -12,6 +14,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -23,17 +26,22 @@ import androidx.core.net.toUri
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
||||
import de.jeanlucmakiola.floret.components.DebugRibbon
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
@@ -49,10 +57,21 @@ import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/** A prefilled create form from an external launch, with the source it came from. */
|
||||
private data class InsertRequest(val form: EventForm, val source: ImportSource)
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
@Inject lateinit var calendarRepository: CalendarRepository
|
||||
|
||||
// Which of light/dark the system bars are drawn for. The styles installed
|
||||
// in onCreate read this field live, so androidx's config-change replay
|
||||
// picks up the in-app override instead of the night resource qualifier.
|
||||
private var systemBarsDark = false
|
||||
|
||||
// The occurrence a reminder notification was tapped for (eventId, begin,
|
||||
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
||||
// tap into the running activity; CalendarHost consumes and clears it.
|
||||
@@ -69,7 +88,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// A prefilled new-event form from an external ACTION_INSERT launch (another
|
||||
// app/widget asking us to create an event, issue #30). Consumed once by
|
||||
// CalendarHost, which opens it in the create form for review.
|
||||
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
|
||||
private var requestedInsert by mutableStateOf<InsertRequest?>(null)
|
||||
|
||||
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
||||
// opens the occurrence in the edit form. Same occurrence-key shape as the
|
||||
@@ -96,12 +115,29 @@ class MainActivity : AppCompatActivity() {
|
||||
return
|
||||
}
|
||||
|
||||
enableEdgeToEdge()
|
||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||
Configuration.UI_MODE_NIGHT_YES
|
||||
applyEdgeToEdge()
|
||||
// Android hands the intent that started the task back to onCreate every
|
||||
// time the activity is recreated — on a rotation, or on a restart after
|
||||
// the system reclaimed the process. Re-running a launch that opens one
|
||||
// specific event is what made a single reminder or widget tap re-open
|
||||
// that event on every later launch, an error screen once the event had
|
||||
// been deleted (#309); the user left it long ago, and the activity
|
||||
// restores where they actually were along with its saved state.
|
||||
//
|
||||
// Only those channels are held back. An .ics import or a prefilled
|
||||
// create form keeps no saved state of its own, so re-reading the intent
|
||||
// is what carries one through a recreation.
|
||||
val replaying = savedInstanceState != null || intent.isRelaunch()
|
||||
if (!replaying) {
|
||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||
requestedEditKey = intent.editEventKeyOrNull()
|
||||
}
|
||||
requestedNav = intent.navRequestOrNull()
|
||||
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
|
||||
requestedImportUri = intent.importUriOrNull()
|
||||
requestedInsertForm = intent.insertFormOrNull()
|
||||
requestedEditKey = intent.editEventKeyOrNull()
|
||||
requestedInsert = intent.insertRequestOrNull()
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
setContent {
|
||||
// One activity-scoped SettingsViewModel drives both the theme here
|
||||
@@ -113,6 +149,14 @@ class MainActivity : AppCompatActivity() {
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
}
|
||||
// onCreate can only see the night resource qualifier, not the in-app
|
||||
// override — re-apply from the resolved theme so the bar icons follow
|
||||
// the app's light/dark choice.
|
||||
DisposableEffect(darkTheme) {
|
||||
systemBarsDark = darkTheme
|
||||
applyEdgeToEdge()
|
||||
onDispose {}
|
||||
}
|
||||
// The app-wide clock convention: the time-format preference resolved
|
||||
// against the device's 24-hour system setting, provided once here so
|
||||
// every time label reads it via LocalUse24HourFormat.
|
||||
@@ -125,6 +169,14 @@ class MainActivity : AppCompatActivity() {
|
||||
// re-import stamp AppFontSettings carries, so replacing the file
|
||||
// behind an active "custom" token still refreshes — changes;
|
||||
// "system for both" returns the default scale untouched.
|
||||
// The timeline scale plus the pinch in flight over it (#56). Held
|
||||
// here, above the calendar views, so a zoom survives paging between
|
||||
// weeks and switching between the week and day view.
|
||||
val timelineZoom = rememberTimelineZoom(
|
||||
stored = settings.timelineScale,
|
||||
hours = settings.timelineHours,
|
||||
onPersist = settingsViewModel::setTimelineScale,
|
||||
)
|
||||
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
||||
val typography = remember(fonts, context) {
|
||||
calendulaTypography(
|
||||
@@ -140,7 +192,9 @@ class MainActivity : AppCompatActivity() {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CompositionLocalProvider(
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalShowHourLines provides settings.showHourLines,
|
||||
LocalShowHourGrid provides settings.showHourGrid,
|
||||
LocalTimelineZoom provides timelineZoom,
|
||||
LocalMultiDayCount provides settings.multiDayCount,
|
||||
LocalSoftenColors provides settings.softenColors,
|
||||
) {
|
||||
RootScreen(
|
||||
@@ -151,8 +205,9 @@ class MainActivity : AppCompatActivity() {
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
onInsertConsumed = { requestedInsertForm = null },
|
||||
requestedInsertForm = requestedInsert?.form,
|
||||
requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
|
||||
onInsertConsumed = { requestedInsert = null },
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = { requestedEditKey = null },
|
||||
)
|
||||
@@ -181,6 +236,25 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the transparent edge-to-edge bars for the current [systemBarsDark].
|
||||
* Both scrims are transparent because API 29+ enforces its own contrast and
|
||||
* ignores them anyway.
|
||||
*/
|
||||
private fun applyEdgeToEdge() {
|
||||
val transparent = android.graphics.Color.TRANSPARENT
|
||||
enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.auto(transparent, transparent) { systemBarsDark },
|
||||
navigationBarStyle = SystemBarStyle.auto(transparent, transparent) { systemBarsDark },
|
||||
)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
// Changes made while we were away may never have notified us (#364).
|
||||
calendarRepository.refresh()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Reaching a running UI means startup succeeded; reset the loop trail.
|
||||
@@ -192,10 +266,20 @@ class MainActivity : AppCompatActivity() {
|
||||
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
||||
intent.insertRequestOrNull()?.let { requestedInsert = it }
|
||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this launch is the task being resumed rather than a fresh
|
||||
* delivery — Android sets the flag when an activity is started from the
|
||||
* recents list, where the intent it carries is the one the task was started
|
||||
* with however long ago. `onNewIntent` is the channel a real new request
|
||||
* arrives on while the task lives, so nothing is lost by ignoring these.
|
||||
*/
|
||||
private fun Intent.isRelaunch(): Boolean =
|
||||
flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
|
||||
|
||||
/**
|
||||
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
|
||||
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
|
||||
@@ -219,9 +303,10 @@ class MainActivity : AppCompatActivity() {
|
||||
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
|
||||
* event", i.e. create). The new event's fields ride as CalendarContract
|
||||
* extras; anything omitted falls back to the in-app "new event" defaults in
|
||||
* [buildInsertEventForm].
|
||||
* [buildInsertEventForm]. An intent that names no end time leaves the length
|
||||
* to the default-duration setting ([ImportSource.InsertOpenEnded], #54).
|
||||
*/
|
||||
private fun Intent.insertFormOrNull(): EventForm? {
|
||||
private fun Intent.insertRequestOrNull(): InsertRequest? {
|
||||
// ACTION_EDIT / ACTION_INSERT_OR_EDIT on an existing event route to the
|
||||
// edit form instead ([editEventKeyOrNull]); an id-less one is a create,
|
||||
// as is any plain ACTION_INSERT.
|
||||
@@ -229,9 +314,11 @@ class MainActivity : AppCompatActivity() {
|
||||
((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) &&
|
||||
editEventKeyOrNull() == null)
|
||||
if (!isCreate) return null
|
||||
return buildInsertEventForm(
|
||||
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
||||
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
||||
val beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME)
|
||||
val endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME)
|
||||
val form = buildInsertEventForm(
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
|
||||
title = getStringExtra(CalendarContract.Events.TITLE),
|
||||
description = getStringExtra(CalendarContract.Events.DESCRIPTION),
|
||||
@@ -240,6 +327,10 @@ class MainActivity : AppCompatActivity() {
|
||||
zone = TimeZone.currentSystemDefault(),
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
// An end the intent didn't name — or one [buildInsertEventForm] drops for
|
||||
// landing before the start — leaves the length to the setting.
|
||||
val namesEnd = beginMillis != null && endMillis != null && endMillis >= beginMillis
|
||||
return InsertRequest(form, if (namesEnd) ImportSource.Insert else ImportSource.InsertOpenEnded)
|
||||
}
|
||||
|
||||
/** A Long extra's value, or null when the extra is absent. */
|
||||
|
||||
+52
-17
@@ -1,11 +1,10 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import java.time.Instant
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.allDayLeadDays
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
* Translates an all-day reminder between the **semantic** lead time the UI
|
||||
@@ -32,9 +31,13 @@ private const val MILLIS_PER_MINUTE = 60_000L
|
||||
* event (a whole-day multiple; sub-day remainders are dropped), so it fires at
|
||||
* [timeOfDayMinutes] (minutes from local midnight) in [zone]. The result may be
|
||||
* **negative** — e.g. "at time of event" at 09:00 CEST encodes to −420, meaning
|
||||
* the provider fires *after* DTSTART; this is valid and must not be clamped.
|
||||
* A negative [semanticMinutes] is the "provider default" sentinel and passes
|
||||
* through unchanged.
|
||||
* the provider fires *after* DTSTART; this is valid and must not be clamped. The
|
||||
* one negative it never is is [MINUTES_DEFAULT]: the sentinel and the encoding
|
||||
* share a value space, so an encoding that lands on it is nudged one minute past
|
||||
* it rather than being read back as "use the account default". That sentinel is
|
||||
* also the one [semanticMinutes] that passes through unencoded — a lead time may
|
||||
* itself be negative (a foreign row firing the day *after* the event), and
|
||||
* waving those through would change when they fire.
|
||||
*/
|
||||
internal fun toProviderAllDayMinutes(
|
||||
semanticMinutes: Int,
|
||||
@@ -42,12 +45,13 @@ internal fun toProviderAllDayMinutes(
|
||||
zone: ZoneId,
|
||||
timeOfDayMinutes: Int,
|
||||
): Int {
|
||||
if (semanticMinutes < 0) return semanticMinutes
|
||||
if (semanticMinutes == MINUTES_DEFAULT) return semanticMinutes
|
||||
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
val fire = startDate.minusDays((semanticMinutes / MINUTES_PER_DAY).toLong())
|
||||
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
|
||||
.atZone(zone).toInstant().toEpochMilli()
|
||||
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
||||
val raw = ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
||||
return if (raw == MINUTES_DEFAULT) MINUTES_DEFAULT - 1 else raw
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,21 +75,52 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
|
||||
return LocalDate.of(today.year, month, day)
|
||||
}
|
||||
|
||||
/**
|
||||
* The date an **imported** all-day event's reminder offset is sampled at.
|
||||
*
|
||||
* A one-off fires once, on its own date. A series' `DTSTART` is only an anchor
|
||||
* and may be ancient — Fossify writes a year-less contact birthday at 1970, a
|
||||
* year whose rules predate DST across most of Europe, and a real birth year is
|
||||
* usually older still — so sampling there skews every modern occurrence by the
|
||||
* offset delta (the same trap [nextYearlyOccurrence] exists for). A yearly
|
||||
* series is therefore sampled at its next occurrence of the anchor's month/day,
|
||||
* which shares the season; any other series at [today], which its upcoming
|
||||
* occurrences are near.
|
||||
*
|
||||
* The decode side ([fromProviderAllDayMinutes]) only asks which local *day* the
|
||||
* encoded instant falls on, so it still reads the lead time back correctly from
|
||||
* the anchor's own date.
|
||||
*/
|
||||
internal fun importedAllDayReminderDate(
|
||||
startDate: LocalDate,
|
||||
recurrenceRule: String?,
|
||||
today: LocalDate,
|
||||
): LocalDate = when {
|
||||
recurrenceRule.isNullOrBlank() -> startDate
|
||||
recurrenceRule.contains("FREQ=YEARLY", ignoreCase = true) ->
|
||||
nextYearlyOccurrence(startDate.monthValue, startDate.dayOfMonth, today)
|
||||
startDate.isBefore(today) -> today
|
||||
else -> startDate
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the semantic whole-day lead time from a raw all-day reminder
|
||||
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
|
||||
* returns the right day count regardless of which [timeOfDayMinutes] wrote the
|
||||
* row — including pre-feature rows (raw multiples of 1440, fired at UTC midnight)
|
||||
* and rows written under a different timezone. A negative [rawMinutes] (fire
|
||||
* after DTSTART) folds to day 0.
|
||||
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
|
||||
* detail screen. Delegates to [allDayLeadDays], so the day displayed is the day
|
||||
* the reminder actually fires on.
|
||||
*
|
||||
* [MINUTES_DEFAULT] passes through unchanged, the mirror of the encode side. A
|
||||
* real encoding is often negative, so only that exact value may be treated as the
|
||||
* sentinel — decoding it would land it on the start date and turn "use the
|
||||
* account default" into a concrete at-start alarm.
|
||||
*/
|
||||
internal fun fromProviderAllDayMinutes(
|
||||
rawMinutes: Int,
|
||||
startDate: LocalDate,
|
||||
zone: ZoneId,
|
||||
): Int {
|
||||
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
val fireLocalDate = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE)
|
||||
.atZone(zone).toLocalDate()
|
||||
return ChronoUnit.DAYS.between(fireLocalDate, startDate).toInt() * MINUTES_PER_DAY
|
||||
timeOfDayMinutes: Int,
|
||||
): Int = if (rawMinutes == MINUTES_DEFAULT) {
|
||||
rawMinutes
|
||||
} else {
|
||||
allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
|
||||
}
|
||||
|
||||
+28
-15
@@ -1,22 +1,35 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
/**
|
||||
* Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
|
||||
* `EVENT_COLOR`. The named entries exist for callers that need one specific
|
||||
* hue (the managed special-dates calendars), so they can't drift from the
|
||||
* swatches offered in the colour picker.
|
||||
* The colours offered when creating or editing a calendar; ARGB ints for a raw
|
||||
* `CALENDAR_COLOR` / `EVENT_COLOR`.
|
||||
*
|
||||
* Hues rather than final fills: every surface that draws a calendar colour runs
|
||||
* it through the app's own tone pass first, so these are chosen to stay
|
||||
* distinguishable after it. Shared with Agendula, whose list palette these are —
|
||||
* the two apps are the same family and a calendar and a task list picked from
|
||||
* different-looking sets read as different products.
|
||||
*
|
||||
* The named entries exist for callers that need one specific hue (the managed
|
||||
* special-dates calendars), so they can't drift from the swatches on offer.
|
||||
*/
|
||||
object CalendarColorPalette {
|
||||
val Red = 0xFFD50000.toInt()
|
||||
val Orange = 0xFFE67C00.toInt()
|
||||
val Amber = 0xFFF6BF26.toInt()
|
||||
val Green = 0xFF33B679.toInt()
|
||||
val DarkGreen = 0xFF0B8043.toInt()
|
||||
val Blue = 0xFF039BE5.toInt()
|
||||
val Indigo = 0xFF3F51B5.toInt()
|
||||
val Purple = 0xFF8E24AA.toInt()
|
||||
val Graphite = 0xFF616161.toInt()
|
||||
val Mauve = 0xFF7A5C6B.toInt()
|
||||
val Red = 0xFFD7484A.toInt()
|
||||
val Orange = 0xFFE8743B.toInt()
|
||||
val Amber = 0xFFE0A32E.toInt()
|
||||
val Olive = 0xFF7CA83E.toInt()
|
||||
val Green = 0xFF35A06A.toInt()
|
||||
val Teal = 0xFF19938C.toInt()
|
||||
val Cyan = 0xFF2A9BC4.toInt()
|
||||
val Blue = 0xFF3C74C8.toInt()
|
||||
val Indigo = 0xFF6A5CC0.toInt()
|
||||
val Purple = 0xFF9455B8.toInt()
|
||||
val Pink = 0xFFC94F8E.toInt()
|
||||
|
||||
/** The full palette, in swatch-row order. */
|
||||
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
|
||||
/** The full palette, in swatch-grid order. */
|
||||
val all: List<Int> = listOf(
|
||||
Mauve, Red, Orange, Amber, Olive, Green,
|
||||
Teal, Cyan, Blue, Indigo, Purple, Pink,
|
||||
)
|
||||
}
|
||||
|
||||
+429
-95
@@ -27,12 +27,15 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventSearch
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
|
||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.Instant
|
||||
@@ -54,15 +57,22 @@ import javax.inject.Singleton
|
||||
interface CalendarDataSource {
|
||||
fun calendars(): List<CalendarSource>
|
||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||
fun eventDetail(eventId: Long): EventDetail?
|
||||
/**
|
||||
* [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to
|
||||
* decode their stored offsets back to whole-day lead times (see
|
||||
* [fromProviderAllDayMinutes]).
|
||||
*/
|
||||
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
|
||||
|
||||
/**
|
||||
* Master/one-off events whose title, description or location contains
|
||||
* [query] (case-insensitive), across all calendars, newest first. Reads the
|
||||
* Events table directly so the search is unbounded in time; exception rows
|
||||
* are excluded (see [SearchProjection]). [query] is assumed non-blank.
|
||||
* Master/one-off events that may match [query], across all calendars: every
|
||||
* whitespace-separated token has to appear in the title, description or
|
||||
* location. Reads the Events table directly so the search is unbounded in
|
||||
* time; exception rows are excluded (see [SearchProjection]). A deliberate
|
||||
* superset — [EventSearch] makes the final call and orders the hits.
|
||||
* [query] is assumed non-blank.
|
||||
*/
|
||||
fun searchEvents(query: String): List<EventInstance>
|
||||
fun searchEvents(query: String): List<SearchCandidate>
|
||||
|
||||
/**
|
||||
* The event-colour palette the calendar's account publishes
|
||||
@@ -75,14 +85,25 @@ interface CalendarDataSource {
|
||||
*/
|
||||
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||
|
||||
/**
|
||||
* The same palette **uncurated** — every key the account publishes, in
|
||||
* provider order. Curation is a display concession (it folds look-alikes and
|
||||
* drops the neutrals outright from an oversized palette), so matching a
|
||||
* colour that came from outside the account has to run against the full set:
|
||||
* those keys are all the calendar accepts. See [eventColorPalette].
|
||||
*/
|
||||
fun publishedEventColors(calendarId: Long): List<EventColorOption>
|
||||
|
||||
/**
|
||||
* Every master/one-off event of the writable local calendars, mapped for a
|
||||
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
||||
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
|
||||
* given, only those calendars are exported (still intersected with the
|
||||
* eligible set); `null` exports every eligible calendar.
|
||||
* [allDayReminderTimeMinutes]: needed to write all-day reminders as whole-day
|
||||
* lead times rather than raw provider offsets (see [toIcsEvent]).
|
||||
*/
|
||||
fun exportableEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
|
||||
fun exportableEvents(calendarIds: Set<Long>?, allDayReminderTimeMinutes: Int): List<IcsEvent>
|
||||
|
||||
/**
|
||||
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||
@@ -92,10 +113,19 @@ interface CalendarDataSource {
|
||||
|
||||
/**
|
||||
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
|
||||
* minting one when absent); returns the new `Events._ID`. Reminders are
|
||||
* written as the file's raw lead minutes (METHOD_ALERT).
|
||||
* minting one when absent); returns the new `Events._ID`.
|
||||
*
|
||||
* [colorPalette] is the target account's published event colours
|
||||
* ([publishedEventColors]), looked up once per import;
|
||||
* [allDayReminderTimeMinutes] is the user's preferred all-day firing time,
|
||||
* applied exactly as a hand-created event's is.
|
||||
*/
|
||||
fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long
|
||||
fun insertImportedEvent(
|
||||
event: ParsedIcsEvent,
|
||||
calendarId: Long,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
colorPalette: List<EventColorOption>,
|
||||
): Long
|
||||
|
||||
/**
|
||||
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
||||
@@ -110,6 +140,28 @@ interface CalendarDataSource {
|
||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||
fun deleteCalendar(id: Long)
|
||||
|
||||
/**
|
||||
* Show or hide the calendar device-wide by writing `Calendars.VISIBLE` — the
|
||||
* app's one visibility model (#75), which also gates its reminders. One of
|
||||
* the three columns the platform documents as app-writable, and device-local.
|
||||
*/
|
||||
fun setCalendarVisible(id: Long, visible: Boolean)
|
||||
|
||||
/**
|
||||
* Whether one calendar is switched on at system level, without reading every
|
||||
* row — for the reminder gate, which sees only a calendar id. Null when the
|
||||
* answer can't be had: no row, or no read permission.
|
||||
*/
|
||||
fun isCalendarVisible(id: Long): Boolean?
|
||||
|
||||
/**
|
||||
* Whether the app holds `WRITE_CALENDAR`, i.e. may write
|
||||
* [setCalendarVisible] at all. Read-only users (READ granted, WRITE denied)
|
||||
* keep their calendar switches app-side instead — see
|
||||
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds].
|
||||
*/
|
||||
fun canWriteCalendars(): Boolean
|
||||
|
||||
/**
|
||||
* Create a local calendar tagged as the special-dates mirror for [type]
|
||||
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
|
||||
@@ -201,14 +253,21 @@ interface CalendarDataSource {
|
||||
): Long
|
||||
|
||||
/**
|
||||
* Change a single occurrence of a recurring event by inserting a
|
||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
||||
* row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||
* Change a single occurrence of a recurring event at [beginMillis] (the
|
||||
* occurrence's `Instances.BEGIN`) to [form]'s values; returns the
|
||||
* `Events._ID` of the row now holding them.
|
||||
*
|
||||
* A series with a `_sync_id` gets a modified-occurrence exception. One
|
||||
* without gets the occurrence excluded from the parent via EXDATE plus a
|
||||
* standalone event carrying the edits — an exception cannot link to its
|
||||
* parent there (Codeberg #234, the same constraint as [deleteOccurrence]).
|
||||
* A row that turns out not to recur at all is edited whole, which is what
|
||||
* [original] is for. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||
*/
|
||||
fun updateOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
form: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long
|
||||
@@ -317,7 +376,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
.build()
|
||||
|
||||
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||
val name = displayName.trim()
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
@@ -341,7 +400,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
|
||||
override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) {
|
||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||
val name = displayName.trim()
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
||||
put(CalendarContract.Calendars.NAME, name)
|
||||
@@ -373,8 +432,39 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
||||
}
|
||||
|
||||
/**
|
||||
* Addressed by appended id on the plain (non-sync-adapter) Calendars URI, one
|
||||
* calendar per call. Both parts are load-bearing:
|
||||
* `CalendarProvider2.updateInTransaction` skips the dirty marking and the
|
||||
* `checkNextAlarm()` reschedule unless the selection is `_id=…`, and the
|
||||
* plain URI is what makes the write apply to synced calendars too.
|
||||
*/
|
||||
override fun setCalendarVisible(id: Long, visible: Boolean) {
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.VISIBLE, if (visible) 1 else 0)
|
||||
}
|
||||
val rows = resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, id),
|
||||
values, null, null,
|
||||
)
|
||||
if (rows == 0) throw WriteFailedException("set calendar visibility id=$id")
|
||||
}
|
||||
|
||||
override fun isCalendarVisible(id: Long): Boolean? {
|
||||
if (!hasCalendarPermission()) return null
|
||||
return resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, id),
|
||||
arrayOf(CalendarContract.Calendars.VISIBLE),
|
||||
null, null, null,
|
||||
)?.use { if (it.moveToFirst()) it.getInt(0) != 0 else null }
|
||||
}
|
||||
|
||||
override fun canWriteCalendars(): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||
val name = displayName.trim()
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
@@ -520,27 +610,33 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
// cancelled exception for the one instance (#47). A NULL status is a
|
||||
// normal, un-cancelled event, so it must survive the filter — a bare
|
||||
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
||||
"${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}",
|
||||
// DELETED = 0: a provider that soft-deletes may keep the row's
|
||||
// instances until a purge (#364).
|
||||
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0",
|
||||
null,
|
||||
CalendarContract.Instances.BEGIN + " ASC",
|
||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||
}
|
||||
|
||||
override fun searchEvents(query: String): List<EventInstance> {
|
||||
override fun searchEvents(query: String): List<SearchCandidate> {
|
||||
ensureObserversRegistered()
|
||||
val trimmed = query.trim()
|
||||
if (trimmed.isEmpty()) return emptyList()
|
||||
// Escape the SQL LIKE wildcards so a literal % or _ in the query matches
|
||||
// itself instead of acting as a wildcard.
|
||||
val escaped = trimmed
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
val like = "%$escaped%"
|
||||
val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
|
||||
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
|
||||
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
|
||||
val tokens = EventSearch.tokenize(query).take(MAX_SEARCH_TOKENS)
|
||||
if (tokens.isEmpty()) return emptyList()
|
||||
// Only a pre-filter: EventSearch re-checks every token, including the
|
||||
// ones the cap above dropped.
|
||||
val patterns = tokens.map { likePatterns(it) }
|
||||
val match = patterns.joinToString(" AND ") { variants ->
|
||||
variants.joinToString(" OR ", prefix = "(", postfix = ")") {
|
||||
"${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
|
||||
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
|
||||
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
|
||||
}
|
||||
}
|
||||
val args = patterns
|
||||
.flatMap { variants -> variants.flatMap { pattern -> List(3) { pattern } } }
|
||||
.toTypedArray()
|
||||
val selection = "($match) AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
|
||||
@@ -548,19 +644,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
SearchProjection.COLUMNS,
|
||||
selection,
|
||||
arrayOf(like, like, like),
|
||||
args,
|
||||
CalendarContract.Events.DTSTART + " DESC",
|
||||
)?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
val out = ArrayList<EventInstance>(c.count)
|
||||
val out = ArrayList<SearchCandidate>(c.count)
|
||||
while (c.moveToNext()) {
|
||||
val description = reader.getString(SearchProjection.IDX_DESCRIPTION)
|
||||
val base = reader.toSearchResult() ?: continue
|
||||
// A recurring master's DTSTART is the series start; show its
|
||||
// nearest occurrence instead so the date is the one the user
|
||||
// actually cares about (and sorting reflects it).
|
||||
val recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||
!reader.getString(SearchProjection.IDX_RDATE).isNullOrEmpty()
|
||||
out += if (recurring) {
|
||||
// A recurring master's DTSTART is the series start; date the row
|
||||
// by its nearest occurrence instead.
|
||||
val event = if (base.isRecurring) {
|
||||
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
|
||||
base.copy(
|
||||
start = begin.toKotlinInstantFromEpochMillis(),
|
||||
@@ -570,11 +664,44 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
} else {
|
||||
base
|
||||
}
|
||||
// The raw title, so matching can't catch on the placeholder an
|
||||
// untitled event is drawn with.
|
||||
out += SearchCandidate(
|
||||
event = event,
|
||||
description = description,
|
||||
title = reader.getString(SearchProjection.IDX_TITLE),
|
||||
)
|
||||
}
|
||||
out
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* One token as the `LIKE` patterns to OR together. SQLite folds case for
|
||||
* ASCII only, so a token with cased non-ASCII letters is also queried lower-,
|
||||
* upper- and title-cased, which is how "ärzte" reaches "Ärzte". Wildcarding
|
||||
* those letters away instead would make "москва" `%______%` — a full scan.
|
||||
*/
|
||||
private fun likePatterns(token: String): List<String> {
|
||||
if (token.none { it.code > 127 && (it.isUpperCase() || it.isLowerCase()) }) {
|
||||
return listOf(likePattern(token))
|
||||
}
|
||||
val lower = token.lowercase()
|
||||
return listOf(token, lower, token.uppercase(), lower.replaceFirstChar(Char::uppercaseChar))
|
||||
.distinct()
|
||||
.map(::likePattern)
|
||||
}
|
||||
|
||||
private fun likePattern(token: String): String {
|
||||
val sb = StringBuilder("%")
|
||||
for (c in token) {
|
||||
// A literal wildcard from the query matches itself.
|
||||
if (c == '%' || c == '_' || c == '\\') sb.append('\\')
|
||||
sb.append(c)
|
||||
}
|
||||
return sb.append('%').toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* The occurrence of [eventId] nearest to now: the soonest upcoming one
|
||||
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
|
||||
@@ -610,7 +737,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventDetail(eventId: Long): EventDetail? {
|
||||
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? {
|
||||
val attendees = queryAttendees(eventId)
|
||||
val reminders = queryReminders(eventId)
|
||||
return resolver.query(
|
||||
@@ -619,11 +746,16 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
null, null, null,
|
||||
)?.use { c ->
|
||||
if (!c.moveToFirst()) null
|
||||
else CursorColumnReader(c).toEventDetailCore(attendees, reminders)
|
||||
else CursorColumnReader(c).toEventDetailCore(
|
||||
attendees, reminders, allDayReminderTimeMinutes,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> {
|
||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
publishedEventColors(calendarId).curatedForPicker()
|
||||
|
||||
override fun publishedEventColors(calendarId: Long): List<EventColorOption> {
|
||||
val account = calendarAccount(calendarId) ?: return emptyList()
|
||||
return resolver.query(
|
||||
CalendarContract.Colors.CONTENT_URI,
|
||||
@@ -641,11 +773,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
||||
}
|
||||
?.filter { it.key.isNotEmpty() }
|
||||
?.curatedForPicker()
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
|
||||
override fun exportableEvents(
|
||||
calendarIds: Set<Long>?,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): List<IcsEvent> {
|
||||
// Only the local calendars the app owns and can write — synced calendars
|
||||
// already have a backup (their server). Exclude the managed special-dates
|
||||
// mirror calendars: their events are derived from contacts, not authored
|
||||
@@ -680,6 +814,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
reader.toIcsEvent(
|
||||
reminderMinutes = queryReminders(eventId).map { it.minutes },
|
||||
calendarName = names[calendarId],
|
||||
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
|
||||
)
|
||||
}
|
||||
} ?: emptyList()
|
||||
@@ -688,76 +823,83 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Events.UID_2445),
|
||||
// DELETED rows linger until a sync adapter purges them; counting those
|
||||
// as present would make a re-import skip everything the user has since
|
||||
// deleted, reporting "all duplicates" and importing nothing.
|
||||
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||
"${CalendarContract.Events.UID_2445} IS NOT NULL",
|
||||
"${CalendarContract.Events.UID_2445} IS NOT NULL AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0",
|
||||
arrayOf(calendarId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
|
||||
} ?: emptySet()
|
||||
|
||||
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
|
||||
val startMillis = event.start.toEpochMillis()
|
||||
val endMillis = event.end.toEpochMillis()
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||
override fun insertImportedEvent(
|
||||
event: ParsedIcsEvent,
|
||||
calendarId: Long,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
colorPalette: List<EventColorOption>,
|
||||
): Long {
|
||||
val values = buildImportedEventValues(
|
||||
event = event,
|
||||
calendarId = calendarId,
|
||||
// Preserve the file's UID so a re-import dedups against it; mint one
|
||||
// only when the source event carried none.
|
||||
put(
|
||||
CalendarContract.Events.UID_2445,
|
||||
event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
||||
)
|
||||
put(CalendarContract.Events.TITLE, event.summary.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, startMillis)
|
||||
if (event.recurrenceRule == null) {
|
||||
put(CalendarContract.Events.DTEND, endMillis)
|
||||
} else {
|
||||
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
||||
put(
|
||||
CalendarContract.Events.DURATION,
|
||||
importDuration(startMillis, endMillis, event.isAllDay),
|
||||
)
|
||||
}
|
||||
// All-day rows live at UTC midnights (the file already encodes them so);
|
||||
// timed rows keep the event's own zone.
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
||||
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
||||
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
||||
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||
}
|
||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||
uid = event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
||||
palette = colorPalette,
|
||||
)
|
||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
|
||||
?: throw WriteFailedException("import event into calendar id=$calendarId")
|
||||
val eventId = ContentUris.parseId(uri)
|
||||
// Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent).
|
||||
event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes ->
|
||||
val zone = ZoneId.systemDefault()
|
||||
// An all-day reminder is stored the same way a hand-created one is, so it
|
||||
// fires at the time the user picked rather than at UTC midnight — sampled
|
||||
// where it will actually fire, not at an ancient recurrence anchor
|
||||
// (see [importedAllDayReminderDate]).
|
||||
val reminderDate = if (event.isAllDay) {
|
||||
importedAllDayReminderDate(
|
||||
startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds())
|
||||
.atZone(ZoneOffset.UTC).toLocalDate(),
|
||||
recurrenceRule = event.recurrenceRule,
|
||||
today = LocalDate.now(zone),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
event.semanticReminderMinutes().forEach { minutes ->
|
||||
val providerMinutes = if (reminderDate != null) {
|
||||
toProviderAllDayMinutes(
|
||||
semanticMinutes = minutes,
|
||||
startDate = reminderDate,
|
||||
zone = zone,
|
||||
timeOfDayMinutes = allDayReminderTimeMinutes,
|
||||
)
|
||||
} else {
|
||||
minutes
|
||||
}
|
||||
val reminder = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||
put(CalendarContract.Reminders.MINUTES, providerMinutes)
|
||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||
// The event row is already in. A reminder that won't attach costs
|
||||
// that one alarm, not the event: throwing here would have the import
|
||||
// count an event it did create as failed, and the retry would then
|
||||
// skip it as a duplicate.
|
||||
val attached = try {
|
||||
resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Reminder insert threw for imported event $eventId", e)
|
||||
null
|
||||
}
|
||||
if (attached == null) {
|
||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
|
||||
}
|
||||
}
|
||||
return eventId
|
||||
}
|
||||
|
||||
/** Provider DURATION for an imported recurring row: whole days / seconds. */
|
||||
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
||||
val span = (endMillis - startMillis).coerceAtLeast(0)
|
||||
return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S"
|
||||
}
|
||||
|
||||
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
||||
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
|
||||
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
|
||||
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
|
||||
}
|
||||
|
||||
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
||||
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
||||
@@ -878,10 +1020,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
updated: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
) {
|
||||
val row = querySeriesRow(eventId)
|
||||
val values = buildEventUpdateValues(
|
||||
original = original,
|
||||
updated = updated,
|
||||
seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis,
|
||||
seriesDtStartMillis = row.dtStartMillis,
|
||||
seriesExdate = row.exdate,
|
||||
zone = ZoneId.systemDefault(),
|
||||
)
|
||||
if (values.isNotEmpty()) {
|
||||
@@ -1093,9 +1237,22 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
override fun updateOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
form: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long {
|
||||
val row = querySeriesRow(eventId)
|
||||
// Not a series, so there is no occurrence to single out: the row *is* the
|
||||
// event, and an exception with nothing to be an exception to would leave
|
||||
// an orphan holding the edit (the same test deleteOccurrence makes).
|
||||
if (!row.recurs) {
|
||||
updateEvent(eventId, original, form, allDayReminderTimeMinutes)
|
||||
return eventId
|
||||
}
|
||||
// EXDATE needs no _sync_id; a modified exception does.
|
||||
if (row.syncId == null) {
|
||||
return detachOccurrence(eventId, beginMillis, row, form, allDayReminderTimeMinutes)
|
||||
}
|
||||
// The provider clones the series row and applies these values on top.
|
||||
val values = buildOccurrenceExceptionValues(
|
||||
form = form,
|
||||
@@ -1107,6 +1264,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
values.toContentValues(),
|
||||
) ?: throw WriteFailedException("modify occurrence event id=$eventId begin=$beginMillis")
|
||||
val exceptionId = ContentUris.parseId(uri)
|
||||
form.colorKey?.let { key ->
|
||||
resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, exceptionId),
|
||||
ContentValues().apply { put(CalendarContract.Events.EVENT_COLOR_KEY, key) },
|
||||
null, null,
|
||||
)
|
||||
}
|
||||
// Whether the provider copied the parent's reminder rows is its
|
||||
// business — reconciling against the actual rows handles both ways.
|
||||
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
|
||||
@@ -1114,6 +1278,90 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
return exceptionId
|
||||
}
|
||||
|
||||
/**
|
||||
* "Edit only this event" on a series with **no `_sync_id`**: drop the
|
||||
* occurrence from the parent with EXDATE and insert the edited values as a
|
||||
* standalone event on the same calendar.
|
||||
*
|
||||
* A modified exception attaches to its parent only through `ORIGINAL_SYNC_ID`,
|
||||
* exactly like the cancelled one [deleteOccurrence] documents; with no
|
||||
* `_sync_id` the link never forms and the edit is lost (Codeberg #234).
|
||||
* EXDATE plus a standalone row needs no link — what a detached instance
|
||||
* degrades to without a `RECURRENCE-ID` to carry it.
|
||||
*
|
||||
* The detached row keeps no stored link back to its series, so: it no longer
|
||||
* travels with it ([moveEvent] copies the master and its `ORIGINAL_ID`
|
||||
* children, and this is neither); its EXDATE hole is an absolute instant, so
|
||||
* re-timing the whole series brings the occurrence back beside the copy (a
|
||||
* #47 delete resurrects the same way); and it is built from the form, not
|
||||
* cloned, so `ORGANIZER`, `STATUS` and the attendee rows [reconcileAttendees]
|
||||
* preserves are dropped — the same limitation as [moveEvent].
|
||||
*
|
||||
* Insert first, so a failure leaves the series untouched
|
||||
* ([updateEventFromOccurrence]'s discipline); roll the new row back if the
|
||||
* EXDATE update then fails, since it would be a visible duplicate. The
|
||||
* reverse order risks the worse outcome — an excluded occurrence with no
|
||||
* replacement, i.e. an edit that quietly deletes.
|
||||
*/
|
||||
private fun detachOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
row: SeriesRow,
|
||||
form: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long {
|
||||
// Already detached (or deleted) from a stale screen still pointing at the
|
||||
// parent: the EXDATE merge would fold the repeat away and still report a
|
||||
// changed row, quietly leaving a *second* standalone copy.
|
||||
if (exdateContains(row.exdate, beginMillis, row.allDay != 0, row.timezone)) {
|
||||
throw NoSuchEventException(eventId)
|
||||
}
|
||||
// Reminders, guests and colour come along like any new event, and so does
|
||||
// a fresh UID — the detached row is a separate event now, and sharing the
|
||||
// parent's would collide with it in .ics restore dedup.
|
||||
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
|
||||
val values = buildOccurrenceExdateValues(
|
||||
existingExdate = row.exdate,
|
||||
occurrenceMillis = beginMillis,
|
||||
dtStartMillis = row.dtStartMillis,
|
||||
rrule = row.rrule,
|
||||
rdate = row.rdate,
|
||||
duration = row.duration,
|
||||
timezone = row.timezone,
|
||||
allDay = row.allDay,
|
||||
)
|
||||
// Rows touched, not occurrences excluded — 1 whenever the series row still
|
||||
// exists. It catches the row disappearing under us, not an EXDATE the
|
||||
// provider's expansion fails to match.
|
||||
val updatedRows = try {
|
||||
resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
values.toContentValues(), null, null,
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
rollBackDetached(detachedId)
|
||||
throw t
|
||||
}
|
||||
if (updatedRows == 0) {
|
||||
rollBackDetached(detachedId)
|
||||
throw WriteFailedException(
|
||||
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
|
||||
)
|
||||
}
|
||||
return detachedId
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the standalone row [detachOccurrence] inserted before its EXDATE
|
||||
* update failed. Best effort: the caller is already throwing, and the worst
|
||||
* case is the duplicate we were avoiding — never a lost occurrence.
|
||||
*/
|
||||
private fun rollBackDetached(detachedId: Long) {
|
||||
runCatching { deleteEvent(detachedId) }.onFailure {
|
||||
Log.w(TAG, "Failed to roll back detached occurrence $detachedId", it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateEventFromOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
@@ -1130,10 +1378,71 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
// Insert the new series first: if it fails, the original is untouched.
|
||||
val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
|
||||
carrySplitExdate(newEventId, row, beginMillis, original, updated)
|
||||
truncateSeries(eventId, row, beginMillis)
|
||||
return newEventId
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry the [parent]'s exclusions for occurrences past [beginMillis] onto the
|
||||
* series [newEventId] that now owns them, re-timed by the shift the split
|
||||
* applied ([shiftedExdate]/[exdateAfter]). [insertEvent] builds the new row
|
||||
* from the form, which knows nothing about them, so without this every
|
||||
* occurrence the user had deleted from the tail of the series comes back.
|
||||
*
|
||||
* The whole time/recurrence set rides along with EXDATE for the reason
|
||||
* [buildOccurrenceExdateValues] documents: on its own the provider does not
|
||||
* read an EXDATE write as a recurrence change, and leaves the instances it
|
||||
* expanded on insert standing.
|
||||
*
|
||||
* A failure rolls the new series back and throws, so the split fails whole
|
||||
* rather than landing with the exclusions quietly dropped — the parent is
|
||||
* still untruncated at this point, so the event is left exactly as it was.
|
||||
*/
|
||||
private fun carrySplitExdate(
|
||||
newEventId: Long,
|
||||
parent: SeriesRow,
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
) {
|
||||
// Dropping the recurrence in the same save leaves a one-off tail, and an
|
||||
// exclusion means nothing on a row that doesn't recur.
|
||||
if (updated.rrule.isNullOrBlank()) return
|
||||
val carried = shiftedExdate(
|
||||
existingExdate = exdateAfter(
|
||||
parent.exdate,
|
||||
beginMillis,
|
||||
original.isAllDay,
|
||||
parent.timezone,
|
||||
),
|
||||
original = original,
|
||||
updated = updated,
|
||||
zone = ZoneId.systemDefault(),
|
||||
) ?: return
|
||||
val row = querySeriesRow(newEventId)
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Events.EXDATE, carried)
|
||||
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
|
||||
put(CalendarContract.Events.RRULE, row.rrule)
|
||||
put(CalendarContract.Events.DURATION, row.duration)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
|
||||
put(CalendarContract.Events.ALL_DAY, row.allDay)
|
||||
}
|
||||
try {
|
||||
val rows = resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, newEventId),
|
||||
values, null, null,
|
||||
)
|
||||
if (rows == 0) {
|
||||
throw WriteFailedException("carry exdate onto split series id=$newEventId")
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
runCatching { deleteEvent(newEventId) }
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
|
||||
val row = querySeriesRow(eventId)
|
||||
// From the first occurrence on = the whole series; also the fallback
|
||||
@@ -1186,6 +1495,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events._SYNC_ID,
|
||||
CalendarContract.Events.EXDATE,
|
||||
CalendarContract.Events.RDATE,
|
||||
),
|
||||
null, null, null,
|
||||
)?.use { c ->
|
||||
@@ -1198,6 +1508,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
allDay = c.getInt(4),
|
||||
syncId = c.getString(5),
|
||||
exdate = c.getString(6),
|
||||
rdate = c.getString(7),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -1213,7 +1524,16 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
/** Null on a local calendar (and before a synced event's first push). */
|
||||
val syncId: String? = null,
|
||||
val exdate: String? = null,
|
||||
val rdate: String? = null,
|
||||
) {
|
||||
/**
|
||||
* Whether this row expands into more than itself. RDATE counts as much as
|
||||
* RRULE: a sync adapter may list the occurrences instead of naming a rule,
|
||||
* and the provider expands either — as does the read side, which marks
|
||||
* such a row recurring too.
|
||||
*/
|
||||
val recurs: Boolean get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
|
||||
|
||||
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
||||
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
||||
beginMillis = beginMillis,
|
||||
@@ -1404,6 +1724,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
|
||||
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||
val row = querySeriesRow(eventId)
|
||||
// Not a series, so the row *is* the event: an exclusion would put a
|
||||
// recurrence set on a one-off, an exception would leave an orphan.
|
||||
if (!row.recurs) {
|
||||
deleteEvent(eventId)
|
||||
return
|
||||
}
|
||||
if (row.syncId == null) {
|
||||
// No _sync_id — a local calendar, or a synced event not pushed yet.
|
||||
// A cancelled exception can only attach to its parent through
|
||||
@@ -1417,6 +1743,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
occurrenceMillis = beginMillis,
|
||||
dtStartMillis = row.dtStartMillis,
|
||||
rrule = row.rrule,
|
||||
rdate = row.rdate,
|
||||
duration = row.duration,
|
||||
timezone = row.timezone,
|
||||
allDay = row.allDay,
|
||||
@@ -1533,5 +1860,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
* next fires beyond it falls back to its series-start date.
|
||||
*/
|
||||
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Tokens the SQL pre-filter is built from. Bounds the statement for a
|
||||
* pasted paragraph; the ranker still requires every token, so a capped
|
||||
* search returns fewer rows to check, never more hits.
|
||||
*/
|
||||
const val MAX_SEARCH_TOKENS = 8
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
||||
val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
return CalendarSource(
|
||||
id = getLong(CalendarProjection.IDX_ID),
|
||||
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
|
||||
?: Fallbacks.UNNAMED_CALENDAR,
|
||||
// Blank when the provider row has no name: the placeholder is a display
|
||||
// string and belongs to the UI, where it can be localised (#329).
|
||||
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME).orEmpty(),
|
||||
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
|
||||
accountType = accountType,
|
||||
color = getInt(CalendarProjection.IDX_COLOR),
|
||||
@@ -31,5 +32,8 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
||||
isManaged = isLocal &&
|
||||
getString(CalendarProjection.IDX_MANAGED_MARKER)
|
||||
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
|
||||
// NULL is treated as syncing — the harmless default.
|
||||
syncsEvents = isNull(CalendarProjection.IDX_SYNC_EVENTS) ||
|
||||
getInt(CalendarProjection.IDX_SYNC_EVENTS) != 0,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
@@ -14,14 +15,17 @@ import kotlin.time.Instant
|
||||
interface CalendarRepository {
|
||||
fun calendars(): Flow<List<CalendarSource>>
|
||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||
|
||||
/** Re-query every open [calendars] / [instances] flow. */
|
||||
fun refresh()
|
||||
suspend fun eventDetail(eventId: Long): EventDetail
|
||||
|
||||
/**
|
||||
* Events whose title, description or location contains [query], with hidden
|
||||
* calendars removed and newest first. Empty when [query] is blank. Searches
|
||||
* the whole history/future (see [CalendarDataSource.searchEvents]).
|
||||
* Candidate matches for [query] with hidden calendars removed; empty when
|
||||
* [query] is blank. Searches the whole history/future and leaves matching
|
||||
* and ranking to [de.jeanlucmakiola.calendula.domain.EventSearch].
|
||||
*/
|
||||
suspend fun searchEvents(query: String): List<EventInstance>
|
||||
suspend fun searchEvents(query: String): List<SearchCandidate>
|
||||
|
||||
/**
|
||||
* The event-colour palette a calendar's account publishes; empty when it
|
||||
@@ -38,6 +42,16 @@ interface CalendarRepository {
|
||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||
suspend fun deleteCalendar(id: Long)
|
||||
|
||||
/**
|
||||
* Show or hide [ids] device-wide (`Calendars.VISIBLE`), which also gates
|
||||
* their reminders — see [CalendarDataSource.setCalendarVisible]. Written one
|
||||
* at a time, in order; a failure part-way leaves the earlier writes standing.
|
||||
*
|
||||
* Without `WRITE_CALENDAR` the choice is kept app-side instead (see
|
||||
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds]).
|
||||
*/
|
||||
suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean)
|
||||
|
||||
/**
|
||||
* Every event of the writable local calendars, ready to serialise into a
|
||||
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
||||
@@ -77,9 +91,15 @@ interface CalendarRepository {
|
||||
|
||||
/**
|
||||
* Change a single occurrence of a recurring event (exception row with the
|
||||
* form's values); returns the exception's `Events._ID`.
|
||||
* form's values); returns the exception's `Events._ID`. [original] is what
|
||||
* the row holds now, for the case where it turns out not to recur.
|
||||
*/
|
||||
suspend fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
|
||||
suspend fun updateOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
form: EventForm,
|
||||
): Long
|
||||
|
||||
/**
|
||||
* Change a recurring event from [beginMillis] onwards (series split);
|
||||
|
||||
+205
-54
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
@@ -9,18 +10,27 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -42,59 +52,143 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
private suspend fun allDayReminderTimeMinutes(): Int =
|
||||
settingsPrefs.allDayReminderTimeMinutes.first()
|
||||
|
||||
// DROP_OLDEST: tryEmit never fails, so a tick that lands while a subscriber
|
||||
// still holds the previous one is never lost (#364).
|
||||
private val ticks = MutableSharedFlow<Unit>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
/**
|
||||
* Bumped on every [refresh], so one tick's calendar read can be
|
||||
* shared by everything that needs it (see [calendarsSnapshot]).
|
||||
*/
|
||||
private val generation = AtomicLong(0L)
|
||||
|
||||
init {
|
||||
dataSource.registerChangeListener { ticks.tryEmit(Unit) }
|
||||
dataSource.registerChangeListener(::refresh)
|
||||
}
|
||||
|
||||
override fun calendars(): Flow<List<CalendarSource>> =
|
||||
ticks
|
||||
.onStart { emit(Unit) }
|
||||
.reQuery { dataSource.calendars() }
|
||||
.flowOn(io)
|
||||
// Writes re-query on their own too: some providers never notify the
|
||||
// observer, which left every view stale until a restart (#364).
|
||||
override fun refresh() {
|
||||
generation.incrementAndGet()
|
||||
ticks.tryEmit(Unit)
|
||||
}
|
||||
|
||||
// Instances are filtered by the app-side hidden ∪ disabled calendar sets
|
||||
// (M3): an event is dropped whenever the user has hidden *or* disabled its
|
||||
// calendar. Re-runs when the provider ticks *or* either set changes —
|
||||
// toggling a calendar in the filter sheet or the calendar manager updates
|
||||
// every view immediately. [calendars] stays unfiltered so those screens can
|
||||
// list and re-enable hidden/disabled calendars.
|
||||
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
||||
combine(
|
||||
ticks
|
||||
.onStart { emit(Unit) }
|
||||
.reQuery {
|
||||
dataSource.instances(
|
||||
beginMillis = range.start.toEpochMillis(),
|
||||
endMillis = range.endInclusive.toEpochMillis(),
|
||||
)
|
||||
},
|
||||
prefs.hiddenCalendarIds,
|
||||
prefs.disabledCalendarIds,
|
||||
) { instances, hidden, disabled ->
|
||||
val excluded = hidden + disabled
|
||||
if (excluded.isEmpty()) instances
|
||||
else instances.filterNot { it.calendarId in excluded }
|
||||
/** Run a provider write on [io], then re-query whether or not it threw. */
|
||||
private suspend fun <T> write(block: suspend () -> T): T = withContext(io) {
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-query signal for everything filtered by visibility: the provider's own
|
||||
* notifications, plus every change to the pending switch-off set.
|
||||
*/
|
||||
private fun visibilityTicks(): Flow<Unit> = merge(
|
||||
ticks.onStart { emit(Unit) },
|
||||
// drop(1): the current value is already covered by the tick above.
|
||||
prefs.pendingDisabledCalendarIds.drop(1).map {},
|
||||
)
|
||||
|
||||
// A switch-off not yet written to the provider is folded into the flag
|
||||
// itself, so every consumer reads one visibility (#75). The reconciler goes
|
||||
// to the data source directly — it needs the provider's own answer.
|
||||
override fun calendars(): Flow<List<CalendarSource>> =
|
||||
visibilityTicks().reQuery {
|
||||
val calendars = calendarsSnapshot()
|
||||
val pendingDisabled = prefs.pendingDisabledCalendarIds.first()
|
||||
if (pendingDisabled.isEmpty()) calendars
|
||||
else calendars.map {
|
||||
if (it.id in pendingDisabled) it.copy(isVisibleInSystem = false) else it
|
||||
}
|
||||
}
|
||||
// hidden and disabled both derive from one DataStore, so toggling
|
||||
// either makes both re-emit and combine briefly surfaces the same
|
||||
// list twice — collapse the duplicate so views don't re-render for it.
|
||||
.distinctUntilChanged()
|
||||
.flowOn(io)
|
||||
|
||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||
// 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 searchEvents(query: String): List<EventInstance> = withContext(io) {
|
||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||
dataSource.eventDetail(eventId, allDayReminderTimeMinutes())
|
||||
?: throw NoSuchEventException(eventId)
|
||||
}
|
||||
|
||||
override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) {
|
||||
if (query.isBlank()) return@withContext emptyList()
|
||||
val excluded = prefs.hiddenCalendarIds.first() + prefs.disabledCalendarIds.first()
|
||||
val excluded = prefs.hiddenCalendarIds.first() +
|
||||
prefs.pendingDisabledCalendarIds.first() +
|
||||
invisibleCalendarIds()
|
||||
dataSource.searchEvents(query)
|
||||
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
||||
.let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } }
|
||||
}
|
||||
|
||||
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
@@ -104,7 +198,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
displayName: String,
|
||||
color: Int,
|
||||
description: String?,
|
||||
): Long = withContext(io) {
|
||||
): Long = write {
|
||||
dataSource.createLocalCalendar(displayName, color, description)
|
||||
}
|
||||
|
||||
@@ -113,35 +207,85 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
displayName: String,
|
||||
color: Int,
|
||||
description: String?,
|
||||
) = withContext(io) { dataSource.updateCalendar(id, displayName, color, description) }
|
||||
) = write { dataSource.updateCalendar(id, displayName, color, description) }
|
||||
|
||||
override suspend fun deleteCalendar(id: Long) =
|
||||
withContext(io) { dataSource.deleteCalendar(id) }
|
||||
write { dataSource.deleteCalendar(id) }
|
||||
|
||||
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
|
||||
write {
|
||||
if (dataSource.canWriteCalendars()) {
|
||||
ids.forEach { dataSource.setCalendarVisible(it, visible) }
|
||||
// Nothing of ours is left waiting for the provider once the
|
||||
// write lands (and switching one back on retires its entry).
|
||||
prefs.removePendingDisabledCalendarIds(ids)
|
||||
} else if (visible) {
|
||||
prefs.removePendingDisabledCalendarIds(ids)
|
||||
} else {
|
||||
// Read-only permission: the switch still works, app-side, and
|
||||
// the reconciler flushes it if WRITE_CALENDAR ever arrives.
|
||||
prefs.addPendingDisabledCalendarIds(ids)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
||||
withContext(io) { dataSource.exportableEvents(calendarIds) }
|
||||
withContext(io) { dataSource.exportableEvents(calendarIds, allDayReminderTimeMinutes()) }
|
||||
|
||||
override suspend fun importEvents(
|
||||
targetCalendarId: Long,
|
||||
events: List<ParsedIcsEvent>,
|
||||
): IcsImportSummary = withContext(io) {
|
||||
): IcsImportSummary = write {
|
||||
val existing = dataSource.existingUids(targetCalendarId)
|
||||
// Both are per-calendar, not per-event: looking them up once keeps a
|
||||
// thousand-event restore to two extra queries. The palette is the
|
||||
// uncurated one — an imported colour is matched against every key the
|
||||
// account accepts, not the subset the picker shows.
|
||||
val palette = dataSource.publishedEventColors(targetCalendarId)
|
||||
val allDayMinutes = allDayReminderTimeMinutes()
|
||||
var imported = 0
|
||||
var skipped = 0
|
||||
for (event in events) {
|
||||
var failed = 0
|
||||
var notAttempted = 0
|
||||
for ((index, event) in events.withIndex()) {
|
||||
// A known UID means the event is already in this calendar — skip,
|
||||
// keeping a restore idempotent (no overwrite this pass).
|
||||
if (event.uid != null && event.uid in existing) {
|
||||
skipped++
|
||||
} else {
|
||||
dataSource.insertImportedEvent(event, targetCalendarId)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
dataSource.insertImportedEvent(event, targetCalendarId, allDayMinutes, palette)
|
||||
imported++
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
// Not this event's fault: the permission or the target calendar is
|
||||
// gone, so every remaining insert would fail the same way.
|
||||
Log.w(TAG, "Import stopped: the calendar is no longer writable", e)
|
||||
// A known UID would have been skipped whatever happened, so it is
|
||||
// no part of what a retry still has to import.
|
||||
val remaining = events.drop(index)
|
||||
val duplicates = remaining.count { it.uid?.let { uid -> uid in existing } == true }
|
||||
skipped += duplicates
|
||||
notAttempted = remaining.size - duplicates
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
// One event the provider won't take must not cost the user the
|
||||
// rest of the file — foreign exports do carry rows it rejects
|
||||
// outright (a malformed RRULE throws straight out of insert).
|
||||
failed++
|
||||
Log.w(TAG, "Skipped an unimportable event", e)
|
||||
}
|
||||
}
|
||||
IcsImportSummary(imported = imported, skippedDuplicate = skipped)
|
||||
IcsImportSummary(
|
||||
imported = imported,
|
||||
skippedDuplicate = skipped,
|
||||
failed = failed,
|
||||
notAttempted = notAttempted,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||
override suspend fun createEvent(form: EventForm): Long = write {
|
||||
dataSource.insertEvent(form, allDayReminderTimeMinutes())
|
||||
}
|
||||
|
||||
@@ -149,11 +293,11 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
eventId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
) = withContext(io) {
|
||||
) = write {
|
||||
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
|
||||
}
|
||||
|
||||
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
||||
override suspend fun deleteEvent(eventId: Long) = write {
|
||||
dataSource.deleteEvent(eventId)
|
||||
}
|
||||
|
||||
@@ -162,7 +306,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long = withContext(io) {
|
||||
): Long = write {
|
||||
dataSource.moveEvent(
|
||||
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
|
||||
)
|
||||
@@ -171,9 +315,12 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
override suspend fun updateOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
form: EventForm,
|
||||
): Long = withContext(io) {
|
||||
dataSource.updateOccurrence(eventId, beginMillis, form, allDayReminderTimeMinutes())
|
||||
): Long = write {
|
||||
dataSource.updateOccurrence(
|
||||
eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun updateEventFromOccurrence(
|
||||
@@ -181,22 +328,26 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long = withContext(io) {
|
||||
): Long = write {
|
||||
dataSource.updateEventFromOccurrence(
|
||||
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
|
||||
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write {
|
||||
dataSource.deleteOccurrence(eventId, beginMillis)
|
||||
}
|
||||
|
||||
override suspend fun deleteEventFromOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
) = withContext(io) {
|
||||
) = write {
|
||||
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "CalendarRepository"
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
||||
|
||||
+113
@@ -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(
|
||||
attendees: List<Attendee>,
|
||||
reminders: List<Reminder>,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): EventDetail? {
|
||||
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
||||
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
||||
@@ -89,7 +90,13 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
val displayReminders = if (isAllDay) {
|
||||
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
val zone = ZoneId.systemDefault()
|
||||
reminders.map { it.copy(minutes = fromProviderAllDayMinutes(it.minutes, startDate, zone)) }
|
||||
reminders.map {
|
||||
it.copy(
|
||||
minutes = fromProviderAllDayMinutes(
|
||||
it.minutes, startDate, zone, allDayReminderTimeMinutes,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
reminders
|
||||
}
|
||||
@@ -118,6 +125,7 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||
eventColor = eventColor,
|
||||
eventColorKey = eventColorKey,
|
||||
isException = !isNull(EventDetailProjection.IDX_ORIGINAL_ID),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,23 @@ package de.jeanlucmakiola.calendula.data.calendar
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.nearestTo
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toJavaLocalDateTime
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.ResolverStyle
|
||||
import java.time.LocalDate as JavaLocalDate
|
||||
import java.time.LocalDateTime as JavaLocalDateTime
|
||||
import java.time.LocalTime as JavaLocalTime
|
||||
|
||||
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
||||
internal data class EventWriteTimes(
|
||||
@@ -134,18 +143,21 @@ internal fun buildEventInsertValues(
|
||||
*
|
||||
* Time fields travel together (the provider validates them as a unit):
|
||||
* - unchanged times, all-day flag and rrule → no time columns at all;
|
||||
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
||||
* - non-recurring result → DTSTART/DTEND, DURATION, RRULE and EXDATE cleared;
|
||||
* - recurring result → the *series* DTSTART moves by the same **wall-clock**
|
||||
* shift the user applied to the displayed occurrence and is re-resolved in the
|
||||
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
|
||||
* replaces DTEND, RRULE is written. This keeps past occurrences intact when
|
||||
* someone edits a later occurrence's time, and keeps the anchor's time-of-day
|
||||
* stable across a DST boundary or a zone change between the two.
|
||||
* replaces DTEND, RRULE is written, and the row's exclusions
|
||||
* ([seriesExdate], the current EXDATE) travel with the anchor. This keeps past
|
||||
* occurrences intact when someone edits a later occurrence's time, and keeps
|
||||
* the anchor's time-of-day stable across a DST boundary or a zone change
|
||||
* between the two.
|
||||
*/
|
||||
internal fun buildEventUpdateValues(
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
seriesDtStartMillis: Long,
|
||||
seriesExdate: String?,
|
||||
zone: ZoneId,
|
||||
): Map<String, Any?> = buildMap {
|
||||
if (updated.title.trim() != original.title.trim()) {
|
||||
@@ -185,6 +197,10 @@ internal fun buildEventUpdateValues(
|
||||
put(CalendarContract.Events.DTEND, newTimes.dtEndMillis)
|
||||
put(CalendarContract.Events.RRULE, null)
|
||||
put(CalendarContract.Events.DURATION, null)
|
||||
// The exclusions named occurrences of a series that no longer exists.
|
||||
// Left behind they are dormant rather than harmless: adding a recurrence
|
||||
// back later would punch the old holes into the new one.
|
||||
put(CalendarContract.Events.EXDATE, null)
|
||||
} else {
|
||||
// Move the series anchor by the *wall-clock* shift the user applied to the
|
||||
// displayed occurrence, then re-resolve it in the event's (possibly new)
|
||||
@@ -206,6 +222,14 @@ internal fun buildEventUpdateValues(
|
||||
put(CalendarContract.Events.DTEND, null)
|
||||
put(CalendarContract.Events.RRULE, updated.rrule)
|
||||
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
||||
// An EXDATE stamp is an absolute instant, so it excludes an occurrence
|
||||
// only while the series keeps generating one at exactly that instant. The
|
||||
// anchor has just moved, so every occurrence regenerates elsewhere and a
|
||||
// stamp left behind matches none of them — the occurrence the user deleted
|
||||
// comes back. Move the stamps the same way the anchor moved.
|
||||
shiftedExdate(seriesExdate, original, updated, zone)
|
||||
?.takeIf { it != seriesExdate }
|
||||
?.let { put(CalendarContract.Events.EXDATE, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +247,10 @@ internal fun buildEventUpdateValues(
|
||||
* DTSTART + DURATION itself, clearing the inherited RRULE in the process. This
|
||||
* matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made
|
||||
* "only this event" fail on-device (Codeberg #16).
|
||||
*
|
||||
* A palette key is left out: the provider crashes on an exception insert that
|
||||
* carries `EVENT_COLOR_KEY` (Codeberg #338), so the caller sets it with a plain
|
||||
* update once the row exists.
|
||||
*/
|
||||
internal fun buildOccurrenceExceptionValues(
|
||||
form: EventForm,
|
||||
@@ -240,9 +268,24 @@ internal fun buildOccurrenceExceptionValues(
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
|
||||
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
|
||||
putAll(eventColorColumns(form.colorKey, form.color))
|
||||
if (form.colorKey == null) putAll(eventColorColumns(null, form.color))
|
||||
}
|
||||
|
||||
/**
|
||||
* The form as a **detached occurrence**: the same edited values, with the
|
||||
* series rule dropped so [buildEventInsertValues] writes a standalone one-off
|
||||
* row (DTSTART + DTEND, no RRULE/DURATION) at the occurrence's own times.
|
||||
*
|
||||
* The "edit only this event" shape for a series with **no `_sync_id`**, where an
|
||||
* exception row can't attach to its parent at all (Codeberg #234).
|
||||
*
|
||||
* The exception path gets the rule dropped for free — the provider clears the
|
||||
* RRULE it cloned when an exception carries DTSTART + DURATION
|
||||
* ([buildOccurrenceExceptionValues]). Here nothing is cloned, so it is stripped
|
||||
* by hand; leaving it on would insert a second *series* overlapping the first.
|
||||
*/
|
||||
internal fun EventForm.toDetachedOccurrence(): EventForm = copy(rrule = null)
|
||||
|
||||
/**
|
||||
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
|
||||
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
||||
@@ -406,52 +449,336 @@ internal fun buildOccurrenceCancelValues(
|
||||
* first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
|
||||
* re-expands it correctly. All observed on a Pixel; see the #47 notes.
|
||||
*
|
||||
* [rdate] travels with [rrule]: a sync adapter may list a series' occurrences
|
||||
* instead of naming a rule, and rewriting the recurrence set without it would
|
||||
* leave the provider nothing but DTSTART to re-expand from — the collapse this
|
||||
* values map exists to avoid.
|
||||
*
|
||||
* EXDATE is a comma-separated list, so an existing one is appended to (a repeat
|
||||
* of the same occurrence is folded away). All-day series take the `VALUE=DATE`
|
||||
* form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`).
|
||||
* of the same occurrence is folded away — [mergedExdate]).
|
||||
*/
|
||||
internal fun buildOccurrenceExdateValues(
|
||||
existingExdate: String?,
|
||||
occurrenceMillis: Long,
|
||||
dtStartMillis: Long,
|
||||
rrule: String?,
|
||||
rdate: String?,
|
||||
duration: String?,
|
||||
timezone: String?,
|
||||
allDay: Int,
|
||||
): Map<String, Any?> {
|
||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
|
||||
val existing = existingExdate?.split(',')
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
.orEmpty()
|
||||
val merged = (existing + stamp).distinct().joinToString(",")
|
||||
return mapOf(
|
||||
CalendarContract.Events.EXDATE to merged,
|
||||
CalendarContract.Events.DTSTART to dtStartMillis,
|
||||
CalendarContract.Events.RRULE to rrule,
|
||||
CalendarContract.Events.DURATION to duration,
|
||||
CalendarContract.Events.EVENT_TIMEZONE to timezone,
|
||||
CalendarContract.Events.ALL_DAY to allDay,
|
||||
)
|
||||
): Map<String, Any?> = mapOf(
|
||||
CalendarContract.Events.EXDATE to
|
||||
mergedExdate(existingExdate, occurrenceMillis, allDay != 0, timezone),
|
||||
CalendarContract.Events.DTSTART to dtStartMillis,
|
||||
CalendarContract.Events.RRULE to rrule,
|
||||
CalendarContract.Events.RDATE to rdate,
|
||||
CalendarContract.Events.DURATION to duration,
|
||||
CalendarContract.Events.EVENT_TIMEZONE to timezone,
|
||||
CalendarContract.Events.ALL_DAY to allDay,
|
||||
)
|
||||
|
||||
/**
|
||||
* [existingExdate] with the occurrence at [occurrenceMillis] excluded as well.
|
||||
*
|
||||
* Existing stamps are canonicalised first — all-day series take the `VALUE=DATE`
|
||||
* form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`) — so
|
||||
* appending to a list a sync adapter wrote in the other accepted form folds the
|
||||
* repeat away instead of leaving the same day excluded twice in two spellings.
|
||||
*
|
||||
* A stamp in no form Calendula reads makes the whole list untouchable: it is kept
|
||||
* exactly as it is, behind the parameter prefix that tells a reader how to
|
||||
* resolve it, and the new exclusion joins it in the list's own form.
|
||||
* Canonicalising the readable stamps around it would leave absolute stamps under
|
||||
* a prefix claiming a zone for all of them — which is how the unreadable one
|
||||
* quietly changes instant.
|
||||
*/
|
||||
private fun mergedExdate(
|
||||
existingExdate: String?,
|
||||
occurrenceMillis: Long,
|
||||
isAllDay: Boolean,
|
||||
timezone: String?,
|
||||
): String {
|
||||
val listZone = exdateListZone(existingExdate, timezone)
|
||||
val stamps = exdateStamps(existingExdate)
|
||||
val canonical = stamps.map { canonicalExdateStamp(it, isAllDay, listZone) }
|
||||
if (canonical.all { it != null }) {
|
||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
|
||||
return (canonical.filterNotNull() + stamp).distinct().joinToString(",")
|
||||
}
|
||||
val prefix = existingExdate?.exdateParameterPrefix().orEmpty()
|
||||
val stamp = if (isAllDay || prefix.isEmpty()) {
|
||||
formatExdateStamp(occurrenceMillis, isAllDay)
|
||||
} else {
|
||||
Instant.ofEpochMilli(occurrenceMillis).atZone(listZone).format(FLOATING_EXDATE)
|
||||
}
|
||||
return prefix + (stamps + stamp).distinct().joinToString(",")
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [existingExdate] already excludes the occurrence at [occurrenceMillis]
|
||||
* — i.e. it has already been dropped from the series, deleted or detached.
|
||||
*
|
||||
* Guards the detach path against running twice from a stale screen: the EXDATE
|
||||
* merge folds the repeat away silently and the update still reports one row
|
||||
* changed, so a second save would leave a second standalone copy.
|
||||
*/
|
||||
internal fun exdateContains(
|
||||
existingExdate: String?,
|
||||
occurrenceMillis: Long,
|
||||
isAllDay: Boolean,
|
||||
timezone: String?,
|
||||
): Boolean {
|
||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
|
||||
val listZone = exdateListZone(existingExdate, timezone)
|
||||
// Canonicalised rather than compared verbatim: a sync adapter may spell the
|
||||
// same exclusion differently to Calendula, and a guard that misses it lets
|
||||
// the detach run a second time and leave a second standalone copy.
|
||||
return exdateStamps(existingExdate)
|
||||
.any { canonicalExdateStamp(it, isAllDay, listZone) == stamp }
|
||||
}
|
||||
|
||||
/**
|
||||
* [existingExdate] with every stamp re-timed by the same **wall-clock** shift the
|
||||
* series anchor takes from [original] to [updated] — the exclusions' half of the
|
||||
* anchor move [buildEventUpdateValues] performs, and what carries them onto the
|
||||
* new series when "this and following" splits one.
|
||||
*
|
||||
* A stamp is an absolute instant, so it keeps naming its occurrence only if it
|
||||
* moves exactly as the occurrence does: shifted in wall clock and re-resolved in
|
||||
* the event's (possibly new) zone. A millisecond delta would instead bake in the
|
||||
* offset that happened to apply at the edited occurrence — an hour off for any
|
||||
* exclusion on the far side of a DST boundary. All-day-ness is read from
|
||||
* [original] and written from [updated], so the `VALUE=DATE` and date-time forms
|
||||
* convert into each other when the event switches.
|
||||
*
|
||||
* A zoned list is read in the zone its prefix names ([exdateListZone]). Null
|
||||
* when there is nothing to carry: no stamps, or a stamp in a form Calendula
|
||||
* cannot read at all. Those are left exactly as they are rather than guessed at —
|
||||
* a stale stamp excludes nothing, but a mangled one could exclude the wrong
|
||||
* occurrence.
|
||||
*/
|
||||
internal fun shiftedExdate(
|
||||
existingExdate: String?,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
zone: ZoneId,
|
||||
): String? {
|
||||
val stamps = exdateStamps(existingExdate)
|
||||
if (stamps.isEmpty()) return null
|
||||
val fromZone = original.writeZone(zone)
|
||||
val toZone = updated.writeZone(zone)
|
||||
val listZone = exdateListZone(existingExdate, fromZone.id)
|
||||
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
|
||||
return stamps
|
||||
.map { stamp ->
|
||||
val local = parseExdateStamp(stamp, original.isAllDay, fromZone, listZone)
|
||||
?: return null
|
||||
formatExdateStamp(local.plus(wallClockShift), updated.isAllDay, toZone)
|
||||
}
|
||||
.distinct()
|
||||
.joinToString(",")
|
||||
}
|
||||
|
||||
/**
|
||||
* The stamps of [existingExdate] naming occurrences after [beginMillis] — the ones
|
||||
* that belong to the *new* series once "this and following" splits a recurring
|
||||
* event there. The parent keeps the full list; its stamps past the split point are
|
||||
* simply inert once it stops generating those occurrences.
|
||||
*
|
||||
* The occurrence at [beginMillis] itself is never carried. It is the one the user
|
||||
* is editing, so it exists by definition, and honouring a stale exclusion for it
|
||||
* would swallow the edit whole.
|
||||
*
|
||||
* The stamps that qualify come back canonicalised, since the zone prefix that
|
||||
* told the reader how to resolve them does not survive the filter.
|
||||
*
|
||||
* Null when nothing qualifies, or when a stamp can't be read (see [shiftedExdate]).
|
||||
*/
|
||||
internal fun exdateAfter(
|
||||
existingExdate: String?,
|
||||
beginMillis: Long,
|
||||
isAllDay: Boolean,
|
||||
timezone: String?,
|
||||
): String? {
|
||||
val stamps = exdateStamps(existingExdate)
|
||||
if (stamps.isEmpty()) return null
|
||||
val listZone = exdateListZone(existingExdate, timezone)
|
||||
return stamps
|
||||
.mapNotNull { stamp ->
|
||||
val utc = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC, listZone) ?: return null
|
||||
utc.takeIf { it.toInstant(ZoneOffset.UTC).toEpochMilli() > beginMillis }
|
||||
}
|
||||
.map { formatExdateStamp(it, isAllDay, ZoneOffset.UTC) }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.joinToString(",")
|
||||
}
|
||||
|
||||
/**
|
||||
* The individual stamps of an EXDATE column value; it is a comma-separated list,
|
||||
* optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of
|
||||
* a zoned one ([exdateListZone] reads that half).
|
||||
*/
|
||||
private fun exdateStamps(exdate: String?): List<String> = exdate
|
||||
?.withoutExdateParameters()
|
||||
?.split(',')
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
.orEmpty()
|
||||
|
||||
/**
|
||||
* An EXDATE column value with the parameter section its stamps sit behind taken
|
||||
* off.
|
||||
*/
|
||||
internal fun String.withoutExdateParameters(): String = substring(exdateParameterPrefix().length)
|
||||
|
||||
/**
|
||||
* The parameter section an EXDATE list's stamps sit behind, its `;` included, or
|
||||
* empty when it has none. Only a `;` before the first stamp separator is one —
|
||||
* anything later belongs to a stamp, and cutting there would drop every stamp in
|
||||
* front of it.
|
||||
*/
|
||||
private fun String.exdateParameterPrefix(): String {
|
||||
val semicolon = indexOf(';')
|
||||
val comma = indexOf(',')
|
||||
return if (semicolon >= 0 && (comma < 0 || semicolon < comma)) {
|
||||
substring(0, semicolon + 1)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The zone the floating stamps of [exdate] are wall clock in: the one its
|
||||
* parameter prefix names, or [fallback] — the event's own `EVENT_TIMEZONE` —
|
||||
* when there is no prefix, or the device's tz database doesn't know the name it
|
||||
* uses (Exchange spells zones its own way). The series' own zone is the right
|
||||
* guess, since a zoned exclusion list is written in it. UTC last, for a row that
|
||||
* carries no zone either. Stamps already written as UTC instants ignore all this.
|
||||
*
|
||||
* Both spellings of the prefix are read. AOSP's `RecurrenceSet` stores the TZID
|
||||
* parameter's *value* alone (`Europe/Berlin;<stamps>`), and so does DAVx5; the
|
||||
* iCalendar `TZID=<zone>;` some writers keep is the other one. Only matching the
|
||||
* second would miss every list the provider itself wrote.
|
||||
*/
|
||||
internal fun exdateListZone(exdate: String?, fallback: String?): ZoneId =
|
||||
exdate?.exdateZoneName()?.toZoneIdOrNull()
|
||||
?: fallback?.toZoneIdOrNull()
|
||||
?: ZoneOffset.UTC
|
||||
|
||||
/** The zone id an EXDATE list's parameter prefix names, with or without `TZID=`. */
|
||||
private fun String.exdateZoneName(): String {
|
||||
val prefix = exdateParameterPrefix().dropLast(1)
|
||||
return if (prefix.startsWith(TZID_PREFIX, ignoreCase = true)) {
|
||||
prefix.drop(TZID_PREFIX.length)
|
||||
} else {
|
||||
prefix
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toZoneIdOrNull(): ZoneId? = runCatching { ZoneId.of(this) }.getOrNull()
|
||||
|
||||
/** The optional `TZID=` a zoned EXDATE list's zone id may sit behind. */
|
||||
private const val TZID_PREFIX = "TZID="
|
||||
|
||||
/**
|
||||
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
|
||||
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
|
||||
* reads off the UTC calendar day.
|
||||
*/
|
||||
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String {
|
||||
val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC)
|
||||
return if (isAllDay) {
|
||||
"%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth)
|
||||
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String =
|
||||
formatExdateStamp(
|
||||
local = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC).toLocalDateTime(),
|
||||
isAllDay = isAllDay,
|
||||
zone = ZoneOffset.UTC,
|
||||
)
|
||||
|
||||
/**
|
||||
* One EXDATE entry for the occurrence whose wall clock in [zone] is [local]. An
|
||||
* all-day entry keeps only the date (its time-of-day is the anchor's, not the
|
||||
* occurrence's); a timed one is resolved in [zone] and written as a UTC instant.
|
||||
*/
|
||||
private fun formatExdateStamp(local: JavaLocalDateTime, isAllDay: Boolean, zone: ZoneId): String =
|
||||
if (isAllDay) {
|
||||
local.toLocalDate().format(ALL_DAY_EXDATE)
|
||||
} else {
|
||||
"%04d%02d%02dT%02d%02d%02dZ".format(
|
||||
utc.year, utc.monthValue, utc.dayOfMonth,
|
||||
utc.hour, utc.minute, utc.second,
|
||||
)
|
||||
local.atZone(zone).withZoneSameInstant(ZoneOffset.UTC).format(TIMED_EXDATE)
|
||||
}
|
||||
|
||||
/**
|
||||
* [stamp] as the wall clock it names in [zone] — the inverse of
|
||||
* [formatExdateStamp]. Null for anything Calendula cannot read, so a caller can
|
||||
* tell "not ours, leave it alone" from a value it may safely re-time.
|
||||
*
|
||||
* A timed exclusion is read both as the UTC instant Calendula writes and as a
|
||||
* floating stamp, which [listZone] then resolves. An all-day one is read both
|
||||
* bare (`yyyyMMdd`, what Calendula writes) and padded to a midnight stamp, in
|
||||
* either spelling: sync adapters are inconsistent about which they store — the
|
||||
* same forms [exportExDates] reshapes on the way out — and refusing one would
|
||||
* drop every exclusion of a foreign all-day series the moment it is re-timed.
|
||||
*/
|
||||
private fun parseExdateStamp(
|
||||
stamp: String,
|
||||
isAllDay: Boolean,
|
||||
zone: ZoneId,
|
||||
listZone: ZoneId,
|
||||
): JavaLocalDateTime? =
|
||||
if (isAllDay) {
|
||||
parseAllDayExdate(stamp)?.atStartOfDay()
|
||||
} else {
|
||||
parseTimedExdate(stamp, listZone)?.withZoneSameInstant(zone)?.toLocalDateTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* A timed EXDATE stamp as the instant it names: the trailing `Z` form resolved in
|
||||
* UTC, or the floating one resolved in [listZone].
|
||||
*/
|
||||
private fun parseTimedExdate(stamp: String, listZone: ZoneId): ZonedDateTime? =
|
||||
runCatching { JavaLocalDateTime.parse(stamp, TIMED_EXDATE).atZone(ZoneOffset.UTC) }.getOrNull()
|
||||
?: runCatching {
|
||||
JavaLocalDateTime.parse(stamp, FLOATING_EXDATE).atZone(listZone)
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* The calendar day an all-day EXDATE stamp names, bare or padded to a midnight
|
||||
* stamp. A padded stamp naming any other time of day is refused rather than
|
||||
* rounded: it is not a form an all-day series is written in, so it could be
|
||||
* naming something else entirely. Midnight is midnight in any zone, so the
|
||||
* padding's own spelling — trailing `Z` or floating — makes no difference.
|
||||
*/
|
||||
private fun parseAllDayExdate(stamp: String): JavaLocalDate? {
|
||||
runCatching { JavaLocalDate.parse(stamp, ALL_DAY_EXDATE) }.getOrNull()?.let { return it }
|
||||
val padded = parsePaddedExdate(stamp) ?: return null
|
||||
return padded.toLocalDate().takeIf { padded.toLocalTime() == JavaLocalTime.MIDNIGHT }
|
||||
}
|
||||
|
||||
/** An `uuuuMMddTHHmmss` stamp, with or without the trailing `Z`, as its own wall clock. */
|
||||
private fun parsePaddedExdate(stamp: String): JavaLocalDateTime? =
|
||||
runCatching { JavaLocalDateTime.parse(stamp, TIMED_EXDATE) }.getOrNull()
|
||||
?: runCatching { JavaLocalDateTime.parse(stamp, FLOATING_EXDATE) }.getOrNull()
|
||||
|
||||
/**
|
||||
* [stamp] in the form Calendula writes, so two spellings of the same exclusion
|
||||
* compare equal. Null for a stamp in no form it reads.
|
||||
*/
|
||||
internal fun canonicalExdateStamp(
|
||||
stamp: String,
|
||||
isAllDay: Boolean,
|
||||
listZone: ZoneId,
|
||||
): String? = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC, listZone)
|
||||
?.let { formatExdateStamp(it, isAllDay, ZoneOffset.UTC) }
|
||||
|
||||
/** `VALUE=DATE` EXDATE form, for an all-day series. */
|
||||
private val ALL_DAY_EXDATE: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)
|
||||
|
||||
/** UTC date-time EXDATE form, for a timed series. */
|
||||
private val TIMED_EXDATE: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss'Z'").withResolverStyle(ResolverStyle.STRICT)
|
||||
|
||||
/** The same form without the `Z`, wall clock in the list's zone rather than UTC. */
|
||||
private val FLOATING_EXDATE: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss").withResolverStyle(ResolverStyle.STRICT)
|
||||
|
||||
/**
|
||||
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
||||
@@ -499,3 +826,73 @@ internal fun AccessLevel.toProviderValue(): Int = when (this) {
|
||||
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
|
||||
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Events` row for a `.ics` event being imported into [calendarId].
|
||||
*
|
||||
* A recurring row carries RRULE + DURATION (and any EXDATE) with no DTEND; a
|
||||
* one-off carries DTEND. All-day rows live at UTC midnights, exactly as the
|
||||
* parser hands them over.
|
||||
*
|
||||
* [palette] is the target account's published event colours. A calendar that
|
||||
* publishes one rejects a raw `EVENT_COLOR`, so an imported colour — which is
|
||||
* an arbitrary ARGB from a foreign app — is snapped to the nearest key it does
|
||||
* accept; accounts with no palette take the raw value (see [eventColorColumns]).
|
||||
*/
|
||||
internal fun buildImportedEventValues(
|
||||
event: ParsedIcsEvent,
|
||||
calendarId: Long,
|
||||
uid: String,
|
||||
palette: List<EventColorOption>,
|
||||
): Map<String, Any?> = buildMap {
|
||||
val startMillis = event.start.toEpochMilliseconds()
|
||||
val endMillis = event.end.toEpochMilliseconds()
|
||||
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||
put(CalendarContract.Events.UID_2445, uid)
|
||||
put(CalendarContract.Events.TITLE, event.summary.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, startMillis)
|
||||
if (event.recurrenceRule == null) {
|
||||
put(CalendarContract.Events.DTEND, endMillis)
|
||||
} else {
|
||||
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
||||
put(
|
||||
CalendarContract.Events.DURATION,
|
||||
importDuration(startMillis, endMillis, event.isAllDay),
|
||||
)
|
||||
event.exDates.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EXDATE, it.joinToString(",")) }
|
||||
}
|
||||
// All-day rows live at UTC midnights (the file already encodes them so);
|
||||
// timed rows keep the event's own zone.
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
||||
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
||||
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
||||
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||
event.color?.let { color ->
|
||||
val key = palette.nearestTo(color)?.key
|
||||
putAll(eventColorColumns(colorKey = key, color = if (key == null) color else null))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider `DURATION` for an imported recurring row: whole days for an all-day
|
||||
* series, seconds otherwise. An all-day series is never shorter than a day —
|
||||
* the provider expands a zero-length one into no instances at all, so the
|
||||
* series would vanish (which is what a literal read of the Fossify family's
|
||||
* inclusive DTEND used to produce; see "Importing foreign .ics" in
|
||||
* docs/ARCHITECTURE.md).
|
||||
*/
|
||||
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
||||
val span = (endMillis - startMillis).coerceAtLeast(0)
|
||||
return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S"
|
||||
}
|
||||
|
||||
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
||||
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
|
||||
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
|
||||
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/**
|
||||
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
|
||||
@@ -12,14 +15,23 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
* [calendarName] the display name of its calendar (emitted as
|
||||
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
|
||||
* MapColumnReader.
|
||||
*
|
||||
* An all-day row's raw offset has the firing time of day encoded into it and is
|
||||
* normally *negative* (see [AllDayReminderEncoding]); written out literally it
|
||||
* would be dropped as a trigger that fires after the event, losing the reminder.
|
||||
* [allDayReminderTimeMinutes] and [zone] decode it back to the whole-day lead
|
||||
* time the file should carry, which an import re-encodes for its own device.
|
||||
*/
|
||||
internal fun ColumnReader.toIcsEvent(
|
||||
reminderMinutes: List<Int>,
|
||||
calendarName: String?,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
): IcsEvent {
|
||||
val eventId = getLong(EventExportProjection.IDX_ID)
|
||||
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
|
||||
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
|
||||
val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
|
||||
|
||||
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
|
||||
// so the writer can render DTEND. A missing/blank both means a zero-length event.
|
||||
@@ -40,16 +52,77 @@ internal fun ColumnReader.toIcsEvent(
|
||||
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
|
||||
start = dtStart.toKotlinInstantFromEpochMillis(),
|
||||
end = end.toKotlinInstantFromEpochMillis(),
|
||||
isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0,
|
||||
isAllDay = isAllDay,
|
||||
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
|
||||
?: "UTC",
|
||||
recurrenceRule = rrule,
|
||||
exDates = if (rrule == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
exportExDates(
|
||||
getString(EventExportProjection.IDX_EXDATE),
|
||||
isAllDay,
|
||||
getString(EventExportProjection.IDX_EVENT_TIMEZONE),
|
||||
)
|
||||
},
|
||||
location = getString(EventExportProjection.IDX_LOCATION),
|
||||
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
||||
reminderMinutes = reminderMinutes,
|
||||
reminderMinutes = if (isAllDay) {
|
||||
val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
reminderMinutes.map {
|
||||
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
|
||||
}
|
||||
} else {
|
||||
reminderMinutes
|
||||
},
|
||||
status = status,
|
||||
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
|
||||
calendarName = calendarName,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's `EXDATE` as the writer wants it: one stamp per entry, empties
|
||||
* dropped.
|
||||
*
|
||||
* An all-day exclusion names a calendar day, and sync adapters are inconsistent
|
||||
* about whether they write it as a bare `yyyyMMdd` or pad it to a midnight
|
||||
* stamp; the time part is dropped so the exported value is the day either way.
|
||||
* Calendula's own writes ([buildOccurrenceExdateValues]) are already bare.
|
||||
*
|
||||
* A zoned exclusion arrives in AOSP's `RecurrenceSet` form, `<zone>;<stamps>`,
|
||||
* where the prefix names the zone the whole list is wall clock in. [IcsWriter]
|
||||
* emits one bare `EXDATE:` line, and a parameter belongs before that colon, not
|
||||
* inside the value — passed on as-is it would make the line unparseable. The
|
||||
* prefix is therefore taken off and its stamps resolved to UTC instants
|
||||
* ([canonicalExdateStamp], the same reading the write path does), the form every
|
||||
* reader accepts.
|
||||
*
|
||||
* Which zone that resolution uses is [exdateListZone]'s call, the same one the
|
||||
* write path makes: the `TZID=` when this device's tz database knows the name,
|
||||
* else the row's own [eventTimezone]. A stamp is never emitted floating just
|
||||
* because the prefix named a zone we couldn't place — against a `DTSTART;TZID=…`
|
||||
* line every reader would resolve it in its own zone and the exclusion would stop
|
||||
* matching its occurrence.
|
||||
*/
|
||||
internal fun exportExDates(
|
||||
exdate: String?,
|
||||
isAllDay: Boolean,
|
||||
eventTimezone: String? = null,
|
||||
): List<String> {
|
||||
if (exdate.isNullOrBlank()) return emptyList()
|
||||
val zone = exdateListZone(exdate, eventTimezone)
|
||||
return exdate
|
||||
.withoutExdateParameters()
|
||||
.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.map { stamp ->
|
||||
if (isAllDay) {
|
||||
stamp.substringBefore('T')
|
||||
} else {
|
||||
canonicalExdateStamp(stamp, isAllDay = false, listZone = zone) ?: stamp
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventResponse
|
||||
|
||||
private const val TAG = "InstanceMapper"
|
||||
|
||||
@@ -19,8 +21,9 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
||||
return null
|
||||
}
|
||||
|
||||
val rawTitle = getString(InstanceProjection.IDX_TITLE)
|
||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||
// Left blank when the event has no title: the placeholder is a display
|
||||
// string, so it belongs to the UI layer where it can be localised (#321).
|
||||
val title = getString(InstanceProjection.IDX_TITLE).orEmpty()
|
||||
|
||||
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
|
||||
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
|
||||
@@ -38,5 +41,17 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
||||
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
|
||||
color = color,
|
||||
location = getString(InstanceProjection.IDX_LOCATION),
|
||||
response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELF_ATTENDEE_STATUS` as the calendar surfaces read it: a tentative "maybe"
|
||||
* counts as going, and everything that is not an open or refused invitation —
|
||||
* your own events included — falls through to [EventResponse.Going].
|
||||
*/
|
||||
internal fun mapEventResponse(raw: Int): EventResponse = when (raw) {
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED -> EventResponse.Declined
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> EventResponse.Invited
|
||||
else -> EventResponse.Going
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ internal object CalendarProjection {
|
||||
// uses to recognise its own managed calendars, independent of any
|
||||
// stored preference id (which a backup restore / data wipe can lose).
|
||||
MANAGED_MARKER_COLUMN,
|
||||
CalendarContract.Calendars.SYNC_EVENTS,
|
||||
)
|
||||
|
||||
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
||||
@@ -36,6 +37,7 @@ internal object CalendarProjection {
|
||||
const val IDX_ACCESS_LEVEL = 6
|
||||
const val IDX_DESCRIPTION = 7
|
||||
const val IDX_MANAGED_MARKER = 8
|
||||
const val IDX_SYNC_EVENTS = 9
|
||||
}
|
||||
|
||||
internal object InstanceProjection {
|
||||
@@ -50,6 +52,7 @@ internal object InstanceProjection {
|
||||
CalendarContract.Instances.EVENT_COLOR,
|
||||
CalendarContract.Instances.CALENDAR_COLOR,
|
||||
CalendarContract.Instances.EVENT_LOCATION,
|
||||
CalendarContract.Instances.SELF_ATTENDEE_STATUS,
|
||||
)
|
||||
|
||||
const val IDX_INSTANCE_ID = 0
|
||||
@@ -62,6 +65,7 @@ internal object InstanceProjection {
|
||||
const val IDX_EVENT_COLOR = 7
|
||||
const val IDX_CALENDAR_COLOR = 8
|
||||
const val IDX_LOCATION = 9
|
||||
const val IDX_SELF_ATTENDEE_STATUS = 10
|
||||
}
|
||||
|
||||
internal object EventDetailProjection {
|
||||
@@ -87,6 +91,9 @@ internal object EventDetailProjection {
|
||||
// Recurring rows carry DURATION instead of DTEND; the detail screen
|
||||
// needs it to render a series opened without a named occurrence.
|
||||
CalendarContract.Events.DURATION,
|
||||
// Non-null on a modified-occurrence exception row; "no RRULE" alone
|
||||
// can't tell an exception from a master (#68).
|
||||
CalendarContract.Events.ORIGINAL_ID,
|
||||
)
|
||||
|
||||
const val IDX_EVENT_ID = 0
|
||||
@@ -108,15 +115,18 @@ internal object EventDetailProjection {
|
||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||
const val IDX_EVENT_COLOR_KEY = 17
|
||||
const val IDX_DURATION = 18
|
||||
const val IDX_ORIGINAL_ID = 19
|
||||
}
|
||||
|
||||
/**
|
||||
* Master/one-off Events rows for a whole-calendar backup. Unlike
|
||||
* [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across
|
||||
* backups) and `DURATION` (recurring rows carry it instead of DTEND). Modified-
|
||||
* backups) and `DURATION` (recurring rows carry it instead of DTEND), plus
|
||||
* `EXDATE` so a series exports the occurrences deleted from it. Modified-
|
||||
* occurrence and cancelled-exception rows are filtered out by the query
|
||||
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides and EXDATEs aren't
|
||||
* exported yet — a documented v1 limit (import skips them too).
|
||||
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides aren't exported — a
|
||||
* documented v1 limit (import skips them too). EXDATE is unaffected by that
|
||||
* filter: it lives on the master row, not on an exception of its own.
|
||||
*/
|
||||
internal object EventExportProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
@@ -129,6 +139,7 @@ internal object EventExportProjection {
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events.EVENT_TIMEZONE,
|
||||
CalendarContract.Events.RRULE,
|
||||
CalendarContract.Events.EXDATE,
|
||||
CalendarContract.Events.EVENT_LOCATION,
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
CalendarContract.Events.STATUS,
|
||||
@@ -145,11 +156,12 @@ internal object EventExportProjection {
|
||||
const val IDX_ALL_DAY = 6
|
||||
const val IDX_EVENT_TIMEZONE = 7
|
||||
const val IDX_RRULE = 8
|
||||
const val IDX_LOCATION = 9
|
||||
const val IDX_DESCRIPTION = 10
|
||||
const val IDX_STATUS = 11
|
||||
const val IDX_AVAILABILITY = 12
|
||||
const val IDX_CALENDAR_ID = 13
|
||||
const val IDX_EXDATE = 9
|
||||
const val IDX_LOCATION = 10
|
||||
const val IDX_DESCRIPTION = 11
|
||||
const val IDX_STATUS = 12
|
||||
const val IDX_AVAILABILITY = 13
|
||||
const val IDX_CALENDAR_ID = 14
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,6 +186,9 @@ internal object SearchProjection {
|
||||
// display its nearest occurrence, not the series-start DTSTART.
|
||||
CalendarContract.Events.RRULE,
|
||||
CalendarContract.Events.RDATE,
|
||||
// Excerpted, not just filtered on: a hit has to show what it matched.
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||
)
|
||||
|
||||
const val IDX_ID = 0
|
||||
@@ -188,6 +203,8 @@ internal object SearchProjection {
|
||||
const val IDX_LOCATION = 9
|
||||
const val IDX_RRULE = 10
|
||||
const val IDX_RDATE = 11
|
||||
const val IDX_DESCRIPTION = 12
|
||||
const val IDX_SELF_ATTENDEE_STATUS = 13
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,8 +312,3 @@ internal object ReminderProjection {
|
||||
const val IDX_MINUTES = 0
|
||||
const val IDX_METHOD = 1
|
||||
}
|
||||
|
||||
internal object Fallbacks {
|
||||
const val UNNAMED_CALENDAR = "(Unbenannter Kalender)"
|
||||
const val UNTITLED_EVENT = "(Ohne Titel)"
|
||||
}
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
|
||||
/**
|
||||
* "Fire at whatever lead time this account defaults to" — a sentinel in the
|
||||
* `Reminders.MINUTES` column, not an offset, so nothing may do arithmetic on it.
|
||||
* `CalendarContract` never exposes what an account resolves it to, so Calendula
|
||||
* neither fires such a row ([withoutProviderDefaults]) nor decodes it
|
||||
* ([fromProviderAllDayMinutes]); the detail screen names it instead.
|
||||
*
|
||||
* It is the **only** negative that means this. A real all-day offset is often
|
||||
* negative too — the firing time of day is encoded into it — so a `< 0` test
|
||||
* would silence reminders that do fire.
|
||||
*/
|
||||
internal const val MINUTES_DEFAULT = CalendarContract.Reminders.MINUTES_DEFAULT
|
||||
|
||||
/**
|
||||
* Raw provider offsets with the account-default rows dropped, so the pure
|
||||
* planner ([de.jeanlucmakiola.calendula.domain.reminders.planReminders]) only
|
||||
* ever sees offsets it can resolve.
|
||||
*/
|
||||
internal fun Map<Long, List<Int>>.withoutProviderDefaults(): Map<Long, List<Int>> =
|
||||
mapValues { (_, minutes) -> minutes.filter { it != MINUTES_DEFAULT } }
|
||||
@@ -21,8 +21,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||
}.coerceAtLeast(dtStart)
|
||||
|
||||
val rawTitle = getString(SearchProjection.IDX_TITLE)
|
||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||
val title = getString(SearchProjection.IDX_TITLE).orEmpty()
|
||||
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
|
||||
getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
||||
} else {
|
||||
@@ -40,5 +39,8 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
|
||||
color = color,
|
||||
location = getString(SearchProjection.IDX_LOCATION),
|
||||
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
||||
response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataS
|
||||
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import javax.inject.Singleton
|
||||
@@ -46,9 +46,9 @@ abstract class DataBindModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindReminderAlertStore(
|
||||
impl: AndroidReminderAlertStore,
|
||||
): ReminderAlertStore
|
||||
abstract fun bindReminderInstanceSource(
|
||||
impl: ProviderReminderInstanceSource,
|
||||
): ReminderInstanceSource
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
package de.jeanlucmakiola.calendula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* App-side preference for "calendars the user has hidden in this app",
|
||||
* separate from the system's per-calendar VISIBLE flag.
|
||||
* App-side calendar preferences. [hiddenCalendarIds] is the drawer's filter
|
||||
* sheet — a purely in-app declutter that deliberately does *not* suppress
|
||||
* reminders. Switching a calendar off entirely is the system's
|
||||
* `Calendars.VISIBLE` flag, written straight to the provider (#75);
|
||||
* [pendingDisabledCalendarIds] only holds those switch-offs the app has not been
|
||||
* allowed to write yet.
|
||||
*
|
||||
* Persisted as a comma-separated string of Long ids; non-numeric tokens are
|
||||
* silently dropped (defensive — see CalendarPrefsTest).
|
||||
@@ -22,46 +29,56 @@ class CalendarPrefs @Inject constructor(
|
||||
private val store: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
val hiddenCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
||||
prefs[HIDDEN_IDS_KEY].orEmpty()
|
||||
.split(',')
|
||||
.mapNotNull { it.trim().toLongOrNull() }
|
||||
.toSet()
|
||||
}
|
||||
// Both id sets are deduped: the store is shared with SettingsPrefs, so any
|
||||
// unrelated write would otherwise re-emit an identical set.
|
||||
val hiddenCalendarIds: Flow<Set<Long>> = store.data
|
||||
.map { prefs -> prefs[HIDDEN_IDS_KEY].parseIds() }
|
||||
.distinctUntilChanged()
|
||||
|
||||
suspend fun setHiddenCalendarIds(ids: Set<Long>) {
|
||||
store.edit { prefs -> prefs.writeIds(HIDDEN_IDS_KEY, ids) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch-offs the provider does not know about yet, because writing
|
||||
* `Calendars.VISIBLE` needs `WRITE_CALENDAR` (#75). Also inherits the
|
||||
* retired app-local model's set, from the same key.
|
||||
*
|
||||
* Honoured as a display and reminder filter while non-empty, but not a
|
||||
* second visibility model: `CalendarVisibilityReconciler` drains it entry by
|
||||
* entry as soon as the app may write, and nothing adds to it while it may.
|
||||
*/
|
||||
val pendingDisabledCalendarIds: Flow<Set<Long>> = store.data
|
||||
.map { prefs -> prefs[DISABLED_IDS_KEY].parseIds() }
|
||||
.distinctUntilChanged()
|
||||
|
||||
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) =
|
||||
editPendingDisabled { it + ids }
|
||||
|
||||
/**
|
||||
* Drop [ids] from the pending set, one at a time as the reconciler flushes
|
||||
* them, so a run that fails part-way never re-applies what already landed.
|
||||
*/
|
||||
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) =
|
||||
editPendingDisabled { it - ids.toSet() }
|
||||
|
||||
private suspend fun editPendingDisabled(transform: (Set<Long>) -> Set<Long>) {
|
||||
store.edit { prefs ->
|
||||
if (ids.isEmpty()) {
|
||||
prefs.remove(HIDDEN_IDS_KEY)
|
||||
} else {
|
||||
prefs[HIDDEN_IDS_KEY] = ids.sorted().joinToString(",")
|
||||
}
|
||||
prefs.writeIds(DISABLED_IDS_KEY, transform(prefs[DISABLED_IDS_KEY].parseIds()))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* App-side preference for "calendars the user has disabled in this app" — a
|
||||
* heavier level than [hiddenCalendarIds]. A disabled calendar is removed from
|
||||
* every surface (drawer filter, event-form picker, import picker) and its
|
||||
* events never appear; it stays listed only in Settings → Calendars so it can
|
||||
* be re-enabled. Stored exactly like the hidden set; never touches the
|
||||
* system's VISIBLE/SYNC_EVENTS flags, so other calendar apps are unaffected.
|
||||
* Whether the one-time "visibility follows this device" notice is still
|
||||
* owed. Null until the reconciler has evaluated it (which needs the calendar
|
||||
* permission), false once it has been shown or was never needed.
|
||||
*/
|
||||
val disabledCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
||||
prefs[DISABLED_IDS_KEY].orEmpty()
|
||||
.split(',')
|
||||
.mapNotNull { it.trim().toLongOrNull() }
|
||||
.toSet()
|
||||
val visibilityNoticePending: Flow<Boolean?> = store.data.map { prefs ->
|
||||
prefs[VISIBILITY_NOTICE_KEY]
|
||||
}
|
||||
|
||||
suspend fun setDisabledCalendarIds(ids: Set<Long>) {
|
||||
store.edit { prefs ->
|
||||
if (ids.isEmpty()) {
|
||||
prefs.remove(DISABLED_IDS_KEY)
|
||||
} else {
|
||||
prefs[DISABLED_IDS_KEY] = ids.sorted().joinToString(",")
|
||||
}
|
||||
}
|
||||
suspend fun setVisibilityNoticePending(pending: Boolean) {
|
||||
store.edit { prefs -> prefs[VISIBILITY_NOTICE_KEY] = pending }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,6 +96,16 @@ class CalendarPrefs @Inject constructor(
|
||||
companion object {
|
||||
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
||||
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids")
|
||||
internal val VISIBILITY_NOTICE_KEY = booleanPreferencesKey("visibility_notice_pending")
|
||||
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
|
||||
}
|
||||
}
|
||||
|
||||
private fun String?.parseIds(): Set<Long> = orEmpty()
|
||||
.split(',')
|
||||
.mapNotNull { it.trim().toLongOrNull() }
|
||||
.toSet()
|
||||
|
||||
private fun MutablePreferences.writeIds(key: Preferences.Key<String>, ids: Set<Long>) {
|
||||
if (ids.isEmpty()) remove(key) else set(key, ids.sorted().joinToString(","))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.jeanlucmakiola.calendula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* How far reminder delivery has got — the one number replacing the provider's
|
||||
* `CalendarAlerts.STATE` (#75). A scan posts the reminders falling after this
|
||||
* watermark and up to now, then moves it to now, so a scan running twice cannot
|
||||
* post twice while a late one still catches up.
|
||||
*
|
||||
* Unset means "never scanned", not zero: the first scan after an install would
|
||||
* otherwise treat every reminder since the epoch as overdue.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderStatePrefs @Inject constructor(
|
||||
private val store: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
/** The watermark, or `null` before the first scan has ever run. */
|
||||
suspend fun lastScanMillis(): Long? = store.data.map { it[LAST_SCAN_KEY] }.first()
|
||||
|
||||
suspend fun setLastScanMillis(millis: Long) {
|
||||
store.edit { prefs -> prefs[LAST_SCAN_KEY] = millis }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val LAST_SCAN_KEY = longPreferencesKey("reminder_last_scan_millis")
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,13 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.common.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import java.time.ZoneId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -208,14 +213,15 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the week/day timeline draws a faint separator line at each hour
|
||||
* (v2.11). Defaults to OFF — the historical clean look; users opt in.
|
||||
* Whether the week/day timeline seats each hour in its own cell (v2.11, a
|
||||
* separator line until v2.20). Defaults to OFF — the historical flat column;
|
||||
* users opt in. The stored key keeps its original name.
|
||||
*/
|
||||
val showHourLines: Flow<Boolean> = store.data.map { prefs ->
|
||||
val showHourGrid: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[SHOW_HOUR_LINES_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setShowHourLines(enabled: Boolean) {
|
||||
suspend fun setShowHourGrid(enabled: Boolean) {
|
||||
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
|
||||
}
|
||||
|
||||
@@ -269,6 +275,49 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* How tall an hour is drawn in the week and day timelines (#56). Defaults to
|
||||
* [TimelineScale.Regular] — the historical 56dp scale. Holds either a preset
|
||||
* or the height a pinch on the timeline settled at.
|
||||
*/
|
||||
val timelineScale: Flow<TimelineScale> = store.data.map { prefs ->
|
||||
parseTimelineScale(prefs[TIMELINE_SCALE_KEY])
|
||||
}
|
||||
|
||||
suspend fun setTimelineScale(scale: TimelineScale) {
|
||||
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
|
||||
}
|
||||
|
||||
/** How many days the multi-day view shows side by side (#193). */
|
||||
val multiDayCount: Flow<Int> = store.data.map { prefs ->
|
||||
(prefs[MULTI_DAY_COUNT_KEY] ?: DEFAULT_MULTI_DAY_COUNT).coerceIn(MULTI_DAY_COUNTS)
|
||||
}
|
||||
|
||||
suspend fun setMultiDayCount(count: Int) {
|
||||
store.edit { it[MULTI_DAY_COUNT_KEY] = count.coerceIn(MULTI_DAY_COUNTS) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The hours the day, week and multi-day timelines fit to the screen when they
|
||||
* open (#322). Off by default; the hours survive switching it off.
|
||||
*/
|
||||
val timelineHours: Flow<TimelineHours> = store.data.map { prefs ->
|
||||
TimelineHours.of(
|
||||
enabled = prefs[TIMELINE_HOURS_ENABLED_KEY] ?: false,
|
||||
startHour = prefs[TIMELINE_HOURS_START_KEY] ?: TimelineHours.DEFAULT_START_HOUR,
|
||||
endHour = prefs[TIMELINE_HOURS_END_KEY] ?: TimelineHours.DEFAULT_END_HOUR,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setTimelineHours(hours: TimelineHours) {
|
||||
val valid = TimelineHours.of(hours.enabled, hours.startHour, hours.endHour)
|
||||
store.edit {
|
||||
it[TIMELINE_HOURS_ENABLED_KEY] = valid.enabled
|
||||
it[TIMELINE_HOURS_START_KEY] = valid.startHour
|
||||
it[TIMELINE_HOURS_END_KEY] = valid.endHour
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 "+"
|
||||
@@ -284,6 +333,19 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether events can be dragged to another slot in the calendar views (#68,
|
||||
* #173). Defaults to ON. Off means no block registers a drag gesture at all;
|
||||
* rescheduling then goes through the edit form.
|
||||
*/
|
||||
val dragToReschedule: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[DRAG_TO_RESCHEDULE_KEY] ?: true
|
||||
}
|
||||
|
||||
suspend fun setDragToReschedule(enabled: Boolean) {
|
||||
store.edit { it[DRAG_TO_RESCHEDULE_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||
@@ -306,6 +368,23 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The size step the agenda widget draws its text at (#51). Defaults to
|
||||
* [WidgetSize.SMALL], which reproduces its original metrics, so an existing
|
||||
* widget is unchanged until its owner turns the size up.
|
||||
*
|
||||
* This replaced deriving a size tier from the widget's measured size, which
|
||||
* the launcher does not report reliably. The month widget takes no size
|
||||
* setting — it divides the width it is given by seven (#103).
|
||||
*/
|
||||
val widgetSize: Flow<WidgetSize> = store.data.map { prefs ->
|
||||
prefs[WIDGET_SIZE_KEY].toEnum(WidgetSize.SMALL)
|
||||
}
|
||||
|
||||
suspend fun setWidgetSize(size: WidgetSize) {
|
||||
store.edit { it[WIDGET_SIZE_KEY] = size.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agenda shows its top range bar — the "showing …" header and
|
||||
* the session range switcher (v2.11). Default ON.
|
||||
@@ -439,6 +518,46 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a new **timed** event lasts, in minutes (#54). Defaults to
|
||||
* [DEFAULT_EVENT_DURATION] — the historical fixed hour. Per-calendar
|
||||
* overrides in [perCalendarEventDuration] take precedence; all-day events are
|
||||
* date-anchored and ignore it. Resolve with [resolveDefaultEventDuration].
|
||||
*/
|
||||
val defaultEventDurationMinutes: Flow<Int> = store.data.map { prefs ->
|
||||
(prefs[DEFAULT_EVENT_DURATION_KEY] ?: DEFAULT_EVENT_DURATION)
|
||||
.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
||||
}
|
||||
|
||||
suspend fun setDefaultEventDurationMinutes(minutes: Int) {
|
||||
store.edit {
|
||||
it[DEFAULT_EVENT_DURATION_KEY] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-calendar overrides of [defaultEventDurationMinutes], keyed by calendar
|
||||
* id: a calendar **present** in the map gives its new events that length, one
|
||||
* **absent** inherits the global default (there is no "no duration", so this
|
||||
* needs no `none` sentinel). Serialised as `id=minutes;id=minutes`.
|
||||
*/
|
||||
val perCalendarEventDuration: Flow<Map<Long, Int>> = store.data.map { prefs ->
|
||||
parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY])
|
||||
}
|
||||
|
||||
/** [minutes] null drops the override, so the calendar inherits the global default. */
|
||||
suspend fun setCalendarEventDuration(calendarId: Long, minutes: Int?) {
|
||||
store.edit { prefs ->
|
||||
val current = parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY]).toMutableMap()
|
||||
if (minutes == null) {
|
||||
current.remove(calendarId)
|
||||
} else {
|
||||
current[calendarId] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
||||
}
|
||||
prefs[CALENDAR_EVENT_DURATION_KEY] = current.toStoredDurations()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
||||
* for users whose only calendar app this is, reminders are essential; the
|
||||
@@ -476,8 +595,105 @@ class SettingsPrefs @Inject constructor(
|
||||
prefs[REMINDER_ONBOARDING_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setReminderOnboardingDone() {
|
||||
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
||||
/** Cleared, not just set: the wizard's back button re-asks a step (#163). */
|
||||
suspend fun setReminderOnboardingDone(done: Boolean = true) {
|
||||
store.edit { it[REMINDER_ONBOARDING_KEY] = done }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this install is a fresh one still owing the wizard's optional
|
||||
* steps (#163). Armed on any launch that holds the calendar permission and
|
||||
* has not finished the reminder step, and cleared again by
|
||||
* [finishOnboardingWizard], so an existing user who revokes and re-grants
|
||||
* the permission isn't re-onboarded.
|
||||
*/
|
||||
val onboardingWizardArmed: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_WIZARD_ARMED_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun armOnboardingWizard() {
|
||||
store.edit { prefs ->
|
||||
if (prefs[REMINDER_ONBOARDING_KEY] != true) prefs[ONBOARDING_WIZARD_ARMED_KEY] = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the wizard's calendar step has been answered (or skipped). */
|
||||
val onboardingCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_CALENDARS_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingCalendarsDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_CALENDARS_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's hidden-calendars step has been answered (or skipped). */
|
||||
val onboardingHiddenCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_HIDDEN_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingHiddenCalendarsDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_HIDDEN_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's backup step has been answered (or skipped). */
|
||||
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingBackupDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_BACKUP_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's default-view step has been answered (or skipped). */
|
||||
val onboardingViewDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_VIEW_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingViewDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_VIEW_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's month-style step has been answered (or skipped). */
|
||||
val onboardingMonthStyleDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_MONTH_STYLE_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingMonthStyleDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_MONTH_STYLE_KEY] = done }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the wizard's calendar-visibility notice has been read. Separate
|
||||
* from [CalendarPrefs.visibilityNoticePending], which says whether this
|
||||
* install has anything to announce at all.
|
||||
*/
|
||||
val onboardingVisibilityDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_VISIBILITY_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingVisibilityDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_VISIBILITY_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's closing screen has been seen. */
|
||||
val onboardingDoneShown: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_DONE_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingDoneShown(shown: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_DONE_KEY] = shown }
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the wizard for good: the closing screen has been seen and the
|
||||
* install stops counting as fresh, so a later revoke-and-re-grant is only
|
||||
* the permission screen. One edit, so the plan can't see it half-closed.
|
||||
*/
|
||||
suspend fun finishOnboardingWizard() {
|
||||
store.edit { prefs ->
|
||||
prefs[ONBOARDING_DONE_KEY] = true
|
||||
prefs[ONBOARDING_WIZARD_ARMED_KEY] = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -763,10 +979,20 @@ class SettingsPrefs @Inject constructor(
|
||||
if (view in config.enabled) view.name else "!${view.name}"
|
||||
}
|
||||
|
||||
/** Keep the given order (de-duplicated), then append any views it omits. */
|
||||
/** Keep stored order; insert the new multi-day view after Week on upgrades. */
|
||||
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
|
||||
val ordered = seen.distinct()
|
||||
return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered }
|
||||
val ordered = seen.distinct().toMutableList()
|
||||
IMPLEMENTED_VIEWS.forEach { view ->
|
||||
if (view !in ordered) {
|
||||
val index = if (view == CalendarView.MultiDay && CalendarView.Week in ordered) {
|
||||
ordered.indexOf(CalendarView.Week) + 1
|
||||
} else {
|
||||
ordered.size
|
||||
}
|
||||
ordered.add(index, view)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -781,6 +1007,7 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||
internal val WIDGET_SIZE_KEY = stringPreferencesKey("widget_size")
|
||||
internal val AGENDA_SHOW_TODAY_KEY =
|
||||
booleanPreferencesKey("agenda_show_today")
|
||||
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||
@@ -789,14 +1016,37 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
||||
internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style")
|
||||
internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale")
|
||||
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
||||
internal val DRAG_TO_RESCHEDULE_KEY = booleanPreferencesKey("drag_to_reschedule")
|
||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||
internal val DEFAULT_EVENT_DURATION_KEY = intPreferencesKey("default_event_duration_minutes")
|
||||
internal val CALENDAR_EVENT_DURATION_KEY =
|
||||
stringPreferencesKey("per_calendar_event_duration")
|
||||
|
||||
/** A new timed event's length until the user changes it: one hour. */
|
||||
const val DEFAULT_EVENT_DURATION = 60
|
||||
internal const val MIN_EVENT_DURATION = 1
|
||||
/** A day — past that the form is describing a multi-day event, not a default. */
|
||||
internal const val MAX_EVENT_DURATION = 1_440
|
||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
|
||||
internal val ONBOARDING_CALENDARS_KEY =
|
||||
booleanPreferencesKey("onboarding_calendars_done")
|
||||
internal val ONBOARDING_HIDDEN_KEY =
|
||||
booleanPreferencesKey("onboarding_hidden_calendars_done")
|
||||
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
|
||||
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
||||
internal val ONBOARDING_MONTH_STYLE_KEY =
|
||||
booleanPreferencesKey("onboarding_month_style_done")
|
||||
internal val ONBOARDING_VISIBILITY_KEY =
|
||||
booleanPreferencesKey("onboarding_visibility_done")
|
||||
internal val ONBOARDING_DONE_KEY = booleanPreferencesKey("onboarding_done_shown")
|
||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||
booleanPreferencesKey("allow_color_unsupported_calendars")
|
||||
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")
|
||||
@@ -808,8 +1058,14 @@ class SettingsPrefs @Inject constructor(
|
||||
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
|
||||
internal val MULTI_DAY_COUNT_KEY = intPreferencesKey("multi_day_count")
|
||||
internal val TIMELINE_HOURS_ENABLED_KEY = booleanPreferencesKey("timeline_hours_enabled")
|
||||
internal val TIMELINE_HOURS_START_KEY = intPreferencesKey("timeline_hours_start")
|
||||
internal val TIMELINE_HOURS_END_KEY = intPreferencesKey("timeline_hours_end")
|
||||
/** Default snooze delay for the notification "Snooze" action. */
|
||||
const val DEFAULT_SNOOZE_MINUTES = 10
|
||||
const val DEFAULT_MULTI_DAY_COUNT = 3
|
||||
val MULTI_DAY_COUNTS = 3..6
|
||||
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
||||
stringPreferencesKey("per_calendar_reminder_override")
|
||||
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
||||
@@ -886,6 +1142,17 @@ fun resolveDefaultReminder(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The length a new timed event on [calendarId] opens with: that calendar's
|
||||
* override if it has one, otherwise the [global] default. Pure so it can be
|
||||
* unit-tested.
|
||||
*/
|
||||
fun resolveDefaultEventDuration(
|
||||
global: Int,
|
||||
overrides: Map<Long, Int>,
|
||||
calendarId: Long?,
|
||||
): Int = calendarId?.let { overrides[it] } ?: global
|
||||
|
||||
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
||||
private const val WEEK_START_AUTO = "AUTO"
|
||||
|
||||
@@ -933,6 +1200,27 @@ private fun String?.toReminderList(): List<Int> = when {
|
||||
private fun List<Int>.toStoredReminders(): String =
|
||||
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
||||
|
||||
/**
|
||||
* Parse the per-calendar duration map (`id=minutes` entries joined by `;`).
|
||||
* Malformed entries and out-of-range lengths are dropped, so a garbled value
|
||||
* degrades to "inherits the global default" instead of throwing.
|
||||
*/
|
||||
private fun parseDurationOverrides(stored: String?): Map<Long, Int> =
|
||||
stored?.split(ENTRY_SEP).orEmpty().mapNotNull { entry ->
|
||||
val parts = entry.split(KEY_VALUE_SEP)
|
||||
if (parts.size != 2) return@mapNotNull null
|
||||
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
|
||||
val minutes = parts[1].trim().toIntOrNull()
|
||||
?.takeIf { it in SettingsPrefs.MIN_EVENT_DURATION..SettingsPrefs.MAX_EVENT_DURATION }
|
||||
?: return@mapNotNull null
|
||||
calendarId to minutes
|
||||
}.toMap()
|
||||
|
||||
private fun Map<Long, Int>.toStoredDurations(): String =
|
||||
entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, minutes) ->
|
||||
"$id$KEY_VALUE_SEP$minutes"
|
||||
}
|
||||
|
||||
|
||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.Manifest
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.CalendarContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* True when [this] alert belongs to a calendar the user disabled in-app, so its
|
||||
* reminder must be suppressed (mirroring the event filtering in
|
||||
* CalendarRepositoryImpl). Alerts whose calendar is unknown (id 0L — e.g. a
|
||||
* pre-upgrade snooze PendingIntent minted before EXTRA_CALENDAR_ID existed) are
|
||||
* never treated as disabled. This is the one predicate the disabled-calendar
|
||||
* gate is built from: [postableAlerts] here and the choke point in
|
||||
* [ReminderNotifier.post] both use it.
|
||||
*/
|
||||
internal fun ReminderAlert.isForDisabledCalendar(disabledCalendarIds: Set<Long>): Boolean =
|
||||
calendarId != 0L && calendarId in disabledCalendarIds
|
||||
|
||||
/**
|
||||
* The due alerts that should actually surface as notifications: everything
|
||||
* except alerts whose calendar the user has disabled in-app. The caller still
|
||||
* marks the full due set fired, so suppressed alerts are not re-broadcast by the
|
||||
* provider.
|
||||
*/
|
||||
internal fun postableAlerts(
|
||||
due: List<ReminderAlert>,
|
||||
disabledCalendarIds: Set<Long>,
|
||||
): List<ReminderAlert> = due.filterNot { it.isForDisabledCalendar(disabledCalendarIds) }
|
||||
|
||||
/**
|
||||
* Becomes the app that turns the calendar provider's reminder alarms into
|
||||
* visible notifications (the Etar model — the provider broadcasts
|
||||
* `EVENT_REMINDER` at reminder time but posts nothing itself).
|
||||
*
|
||||
* The broadcast's data URI only carries the alarm time, so it is ignored:
|
||||
* we query every still-scheduled, due `CalendarAlerts` row ourselves, post
|
||||
* them, and mark them fired. Posting happens before marking — a crash in
|
||||
* between re-posts silently (same tag) rather than losing the reminder.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class EventReminderReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var alertStore: ReminderAlertStore
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
@Inject lateinit var calendarPrefs: CalendarPrefs
|
||||
@Inject lateinit var suppressedStore: SuppressedReminderStore
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||
val readGranted = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (!readGranted || !notifier.canPost()) return
|
||||
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
if (settingsPrefs.remindersEnabled.first()) {
|
||||
val now = System.currentTimeMillis()
|
||||
val due = alertStore.dueAlerts(now)
|
||||
val disabled = calendarPrefs.disabledCalendarIds.first()
|
||||
val postable = postableAlerts(due, disabled)
|
||||
// Suppress reminders for disabled calendars, but still mark
|
||||
// every due alert fired so the provider stops re-broadcasting
|
||||
// the suppressed ones. Stash those suppressed alerts so
|
||||
// re-enabling their calendar can recover them (they would
|
||||
// otherwise stay STATE_FIRED forever with no re-scan).
|
||||
postable.forEach { notifier.post(it) }
|
||||
alertStore.markFired(due.map { it.alertId }, now)
|
||||
suppressedStore.stash(due - postable.toSet(), now)
|
||||
suppressedStore.purgeExpired(now)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-11
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.reminders
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -18,12 +19,14 @@ import javax.inject.Inject
|
||||
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
||||
* alarm), so the receiver is not exported.
|
||||
*
|
||||
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
|
||||
* already fired, so nothing re-posts it.
|
||||
* - **Dismiss** just cancels the notification — the scan's watermark has moved
|
||||
* past this reminder, so nothing re-posts it.
|
||||
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
||||
* it after the user's snooze delay.
|
||||
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
|
||||
* or dismiss it again.
|
||||
* or dismiss it again — unless the calendar was switched off during the
|
||||
* snooze, which [ReminderNotifier.post] catches — this alarm is its own
|
||||
* trigger, outside the ordinary scan.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ReminderActionReceiver : BroadcastReceiver() {
|
||||
@@ -73,7 +76,14 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
||||
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
|
||||
|
||||
private const val EXTRA_ALERT_ID = "alert_id"
|
||||
/**
|
||||
* Not handled here — the notification body opens the detail screen
|
||||
* directly. It only claims a slot in [requestCode] so that intent stays
|
||||
* distinct from the three this receiver does handle.
|
||||
*/
|
||||
const val ACTION_OPEN = "de.jeanlucmakiola.calendula.reminders.OPEN"
|
||||
|
||||
private const val EXTRA_ALERT_KEY = "alert_key"
|
||||
private const val EXTRA_EVENT_ID = "event_id"
|
||||
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||
private const val EXTRA_BEGIN = "begin"
|
||||
@@ -82,11 +92,16 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
private const val EXTRA_LOCATION = "location"
|
||||
private const val EXTRA_ALL_DAY = "all_day"
|
||||
|
||||
/** An explicit intent to this receiver carrying [alert] as extras. */
|
||||
/**
|
||||
* An explicit intent to this receiver carrying [alert] as extras. The
|
||||
* data URI duplicates no information but is what keeps two reminders'
|
||||
* `PendingIntent`s apart — `filterEquals` never compares extras.
|
||||
*/
|
||||
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
||||
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||
this.action = action
|
||||
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
||||
data = "calendula://reminder/${alert.key}".toUri()
|
||||
putExtra(EXTRA_ALERT_KEY, alert.key)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||
@@ -97,23 +112,25 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable request code per (alert, action) so the three PendingIntents
|
||||
* of one notification stay distinct and don't clobber each other.
|
||||
* A stable request code per (alert, action), so one notification's
|
||||
* PendingIntents stay distinct. The shift keeps the action slot intact;
|
||||
* the top bits it drops are separated by [intent]'s per-reminder URI.
|
||||
*/
|
||||
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||
val actionOffset = when (action) {
|
||||
ACTION_SNOOZE -> 1
|
||||
ACTION_DISMISS -> 2
|
||||
ACTION_SHOW -> 3
|
||||
ACTION_OPEN -> 4
|
||||
else -> 0
|
||||
}
|
||||
return alert.alertId.toInt() * 8 + actionOffset
|
||||
return (alert.key.toInt() shl 3) + actionOffset
|
||||
}
|
||||
|
||||
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
|
||||
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
|
||||
return ReminderAlert(
|
||||
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
||||
key = intent.getLongExtra(EXTRA_ALERT_KEY, 0L),
|
||||
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
||||
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.content.getSystemService
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* True on API < 31 (no restriction), and on 31+ when the exact-alarm capability
|
||||
* is held — auto-granted via `USE_EXACT_ALARM` on API 33+ (Calendula is a
|
||||
* calendar app), user-revocable on 31–32.
|
||||
*/
|
||||
internal fun AlarmManager.canScheduleExactCompat(): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
|
||||
|
||||
/**
|
||||
* Holds the app's own wake-up for the next reminder (#75). Exactly one alarm
|
||||
* exists at a time, for the earliest reminder ahead; every firing re-scans and
|
||||
* re-arms. Exact, with an inexact allow-while-idle fallback where the OS
|
||||
* withholds the capability (API 31–32 with the permission revoked).
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderAlarmScheduler @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
fun scheduleScan(triggerAtMillis: Long) {
|
||||
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||
val pendingIntent = scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
if (alarmManager.canScheduleExactCompat()) {
|
||||
alarmManager.setExactAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
} else {
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */
|
||||
fun cancelScan() {
|
||||
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||
alarmManager.cancel(scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
}
|
||||
|
||||
private fun scanPendingIntent(flags: Int): PendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
SCAN_REQUEST_CODE,
|
||||
Intent(context, ReminderScheduleReceiver::class.java)
|
||||
.setAction(ReminderScheduleReceiver.ACTION_SCAN),
|
||||
flags or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
// Fixed: there is only ever one scan alarm, and re-arming must replace it.
|
||||
const val SCAN_REQUEST_CODE = 0x5CA1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
|
||||
|
||||
/**
|
||||
* One reminder as the notification layer needs it: what to show, and the stable
|
||||
* [key] identifying it across a reboot, a re-scan and a reinstall. Derived from
|
||||
* the reminder itself (see [PlannedReminder.key]) — in-house delivery has no
|
||||
* `CalendarAlerts` row to take an id from (#75).
|
||||
*/
|
||||
data class ReminderAlert(
|
||||
val key: Long,
|
||||
val eventId: Long,
|
||||
val calendarId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||
val title: String,
|
||||
val location: String?,
|
||||
val isAllDay: Boolean,
|
||||
)
|
||||
|
||||
fun PlannedReminder.toAlert(): ReminderAlert = ReminderAlert(
|
||||
key = key,
|
||||
eventId = instance.eventId,
|
||||
calendarId = instance.calendarId,
|
||||
beginMillis = instance.beginMillis,
|
||||
endMillis = instance.endMillis,
|
||||
title = instance.title,
|
||||
location = instance.location,
|
||||
isAllDay = instance.isAllDay,
|
||||
)
|
||||
@@ -1,115 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* One due row of the provider's `CalendarAlerts` table (a join with Events).
|
||||
* Stays in the data layer: alerts feed the notification path only and never
|
||||
* reach a screen, so there is no domain model for them.
|
||||
*/
|
||||
data class ReminderAlert(
|
||||
val alertId: Long,
|
||||
val eventId: Long,
|
||||
val calendarId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||
val title: String,
|
||||
val location: String?,
|
||||
val isAllDay: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Seam over the `CalendarAlerts` table so the receiver logic can be exercised
|
||||
* without a ContentResolver. The provider creates these rows itself — only
|
||||
* for `METHOD_ALERT` reminders (verified in AOSP `CalendarAlarmManager`), so
|
||||
* email reminders never show up here.
|
||||
*/
|
||||
interface ReminderAlertStore {
|
||||
|
||||
/** Alerts that are due (`ALARM_TIME` has passed) and still unhandled. */
|
||||
fun dueAlerts(nowMillis: Long): List<ReminderAlert>
|
||||
|
||||
/**
|
||||
* Mark the given alerts handled (`STATE_FIRED`) so a later broadcast does
|
||||
* not surface them again. Best effort: this write needs `WRITE_CALENDAR`,
|
||||
* which the user may have declined — then re-broadcasts silently replace
|
||||
* the already-posted notifications instead (same tag, alert-once).
|
||||
*/
|
||||
fun markFired(alertIds: List<Long>, nowMillis: Long)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class AndroidReminderAlertStore @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ReminderAlertStore {
|
||||
|
||||
override fun dueAlerts(nowMillis: Long): List<ReminderAlert> = context.contentResolver.query(
|
||||
CalendarContract.CalendarAlerts.CONTENT_URI,
|
||||
PROJECTION,
|
||||
CalendarContract.CalendarAlerts.STATE + " = ? AND " +
|
||||
CalendarContract.CalendarAlerts.ALARM_TIME + " <= ?",
|
||||
arrayOf(
|
||||
CalendarContract.CalendarAlerts.STATE_SCHEDULED.toString(),
|
||||
nowMillis.toString(),
|
||||
),
|
||||
CalendarContract.CalendarAlerts.BEGIN + " ASC",
|
||||
)?.use { c ->
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
add(
|
||||
ReminderAlert(
|
||||
alertId = c.getLong(0),
|
||||
eventId = c.getLong(1),
|
||||
calendarId = c.getLong(2),
|
||||
beginMillis = c.getLong(3),
|
||||
endMillis = c.getLong(4),
|
||||
title = c.getString(5).orEmpty(),
|
||||
location = c.getString(6)?.takeIf { it.isNotBlank() },
|
||||
isAllDay = c.getInt(7) == 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
override fun markFired(alertIds: List<Long>, nowMillis: Long) {
|
||||
if (alertIds.isEmpty()) return
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.CalendarAlerts.STATE, CalendarContract.CalendarAlerts.STATE_FIRED)
|
||||
put(CalendarContract.CalendarAlerts.RECEIVED_TIME, nowMillis)
|
||||
put(CalendarContract.CalendarAlerts.NOTIFY_TIME, nowMillis)
|
||||
}
|
||||
try {
|
||||
context.contentResolver.update(
|
||||
CalendarContract.CalendarAlerts.CONTENT_URI,
|
||||
values,
|
||||
CalendarContract.CalendarAlerts._ID +
|
||||
" IN (" + alertIds.joinToString(",") + ")",
|
||||
null,
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Cannot mark alerts fired without WRITE_CALENDAR", e)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderAlertStore"
|
||||
val PROJECTION = arrayOf(
|
||||
CalendarContract.CalendarAlerts._ID,
|
||||
CalendarContract.CalendarAlerts.EVENT_ID,
|
||||
CalendarContract.CalendarAlerts.CALENDAR_ID,
|
||||
CalendarContract.CalendarAlerts.BEGIN,
|
||||
CalendarContract.CalendarAlerts.END,
|
||||
CalendarContract.CalendarAlerts.TITLE,
|
||||
CalendarContract.CalendarAlerts.EVENT_LOCATION,
|
||||
CalendarContract.CalendarAlerts.ALL_DAY,
|
||||
)
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContentUris
|
||||
import android.provider.CalendarContract
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The read side of in-house reminder delivery: occurrences and 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.
|
||||
// An invitation the user declined is answered — it stays on the calendar
|
||||
// struck through, but it plans nothing (#180). NULL again means "no
|
||||
// answer recorded", which is not a "no".
|
||||
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
|
||||
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||
"${CalendarContract.Instances.STATUS} != " +
|
||||
"${CalendarContract.Events.STATUS_CANCELED}) AND " +
|
||||
"(${CalendarContract.Instances.SELF_ATTENDEE_STATUS} IS NULL OR " +
|
||||
"${CalendarContract.Instances.SELF_ATTENDEE_STATUS} != " +
|
||||
"${CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED})"
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+62
@@ -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 de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
@@ -28,17 +29,20 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Posts one notification per due reminder alert on a dedicated channel.
|
||||
* Tapping opens the event's detail screen; the tag is the alert id, so a
|
||||
* re-broadcast of an alert we couldn't mark fired replaces its notification
|
||||
* silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of
|
||||
* duplicating it.
|
||||
* Posts one notification per due reminder on a dedicated channel. Tapping opens
|
||||
* the event's detail screen.
|
||||
*
|
||||
* The tag is the reminder's stable key, so a scan that posts the same reminder
|
||||
* again — a catch-up pass overlapping the alarm that already fired — replaces
|
||||
* its notification silently ([NotificationCompat.Builder.setOnlyAlertOnce])
|
||||
* instead of stacking a second one.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderNotifier @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val calendarPrefs: CalendarPrefs,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
) {
|
||||
|
||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||
@@ -49,12 +53,23 @@ class ReminderNotifier @Inject constructor(
|
||||
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
suspend fun post(alert: ReminderAlert) {
|
||||
// The single choke point for the disabled-calendar gate: it covers both
|
||||
// the provider broadcast (EventReminderReceiver) and a snoozed re-show
|
||||
// (ReminderActionReceiver), so a calendar disabled after a snooze no
|
||||
// longer notifies — without either receiver duplicating the check.
|
||||
if (alert.isForDisabledCalendar(calendarPrefs.disabledCalendarIds.first())) return
|
||||
/**
|
||||
* The single choke point for "this calendar is switched off", covering the
|
||||
* two paths that reach [post] around the scan's own filter: a snooze armed
|
||||
* before the switch-off, and a read-only install whose switch lives in
|
||||
* [CalendarPrefs].
|
||||
*/
|
||||
private suspend fun isSilenced(calendarId: Long): Boolean =
|
||||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
||||
calendarDataSource.isCalendarVisible(calendarId) == false
|
||||
|
||||
/**
|
||||
* Post [alert], unless its calendar is switched off. Returns whether the
|
||||
* notification was put up, which the snooze re-show path uses to tell a
|
||||
* silenced reminder from a delivered one.
|
||||
*/
|
||||
suspend fun post(alert: ReminderAlert): Boolean {
|
||||
if (isSilenced(alert.calendarId)) return false
|
||||
ensureChannel()
|
||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
@@ -73,6 +88,7 @@ class ReminderNotifier @Inject constructor(
|
||||
is24Hour = is24Hour,
|
||||
today = Instant.now().atZone(zone).toLocalDate(),
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
todayLabel = context.getString(R.string.reminder_day_today),
|
||||
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
||||
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
||||
)
|
||||
@@ -101,16 +117,18 @@ class ReminderNotifier @Inject constructor(
|
||||
.build()
|
||||
try {
|
||||
NotificationManagerCompat.from(context)
|
||||
.notify(alert.alertId.toString(), NOTIFICATION_ID, notification)
|
||||
.notify(alert.key.toString(), NOTIFICATION_ID, notification)
|
||||
} catch (e: SecurityException) {
|
||||
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
||||
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
||||
}
|
||||
// Handled either way — a retry hits the same revoked permission.
|
||||
return true
|
||||
}
|
||||
|
||||
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||
fun cancel(alert: ReminderAlert) {
|
||||
NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID)
|
||||
NotificationManagerCompat.from(context).cancel(alert.key.toString(), NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
||||
@@ -123,7 +141,10 @@ class ReminderNotifier @Inject constructor(
|
||||
|
||||
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
/* requestCode = */ alert.alertId.toInt(),
|
||||
// Shares the per-(alert, action) request-code scheme with the buttons.
|
||||
/* requestCode = */ ReminderActionReceiver.requestCode(
|
||||
alert, ReminderActionReceiver.ACTION_OPEN,
|
||||
),
|
||||
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
|
||||
import 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 })
|
||||
.withoutProviderDefaults(),
|
||||
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
|
||||
}
|
||||
}
|
||||
+53
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
-9
@@ -10,14 +10,13 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
|
||||
* Schedules a one-off exact alarm that re-shows a snoozed reminder. Separate
|
||||
* from [ReminderAlarmScheduler]'s single moving scan alarm: a snooze is pinned
|
||||
* to one reminder and has to outlive the watermark moving past it, so it carries
|
||||
* the reminder in its own intent.
|
||||
*
|
||||
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
|
||||
* broadcast (the Etar model), but a snoozed reminder has no provider backing —
|
||||
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
|
||||
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
|
||||
* back to an inexact allow-while-idle alarm only if the OS withholds the
|
||||
* exact-alarm capability (API 31–32 where the user revoked it).
|
||||
* Falls back to an inexact allow-while-idle alarm where the OS withholds the
|
||||
* exact-alarm capability (API 31–32 with the permission revoked).
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderSnoozeScheduler @Inject constructor(
|
||||
@@ -37,8 +36,8 @@ class ReminderSnoozeScheduler @Inject constructor(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
} else {
|
||||
// Exact alarms revoked (API 31–32): an inexact wake is the honest
|
||||
// best we can do without nagging for SCHEDULE_EXACT_ALARM.
|
||||
// Exact alarms revoked (API 31–32); an inexact wake is the best
|
||||
// available without nagging for SCHEDULE_EXACT_ALARM.
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
|
||||
@@ -27,12 +27,12 @@ import java.util.Locale
|
||||
* alone would be ambiguous)
|
||||
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
|
||||
* already unambiguous)
|
||||
* - all-day, one day: "11 Jun 2026"
|
||||
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
||||
* - all-day, one day: "Tomorrow" / "Thu" / "11 Jun 2026" (same relative rules,
|
||||
* plus [todayLabel] — no time range to lean on)
|
||||
* - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end)
|
||||
*
|
||||
* All-day instances already carry an explicit date, so they never gain a
|
||||
* relative prefix. They store UTC midnights with an exclusive end, so they are
|
||||
* read in UTC and the end day is the last *covered* day.
|
||||
* All-day instances store UTC midnights with an exclusive end, so they are read
|
||||
* in UTC and the end day is the last *covered* day (issue #308).
|
||||
*/
|
||||
fun reminderTimeText(
|
||||
beginMillis: Long,
|
||||
@@ -43,6 +43,7 @@ fun reminderTimeText(
|
||||
is24Hour: Boolean,
|
||||
today: LocalDate,
|
||||
firstDayOfWeek: DayOfWeek,
|
||||
todayLabel: String,
|
||||
tomorrowLabel: String,
|
||||
yesterdayLabel: String,
|
||||
): String {
|
||||
@@ -53,11 +54,16 @@ fun reminderTimeText(
|
||||
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
.minusDays(1)
|
||||
.coerceAtLeast(firstDay)
|
||||
return if (lastDay == firstDay) {
|
||||
dateFormat.format(firstDay)
|
||||
} else {
|
||||
dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay)
|
||||
}
|
||||
val start = relativeDayPrefix(
|
||||
day = firstDay,
|
||||
today = today,
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
locale = locale,
|
||||
dateFormat = dateFormat,
|
||||
tomorrowLabel = tomorrowLabel,
|
||||
yesterdayLabel = yesterdayLabel,
|
||||
) ?: todayLabel
|
||||
return if (lastDay == firstDay) start else start + RANGE + dateFormat.format(lastDay)
|
||||
}
|
||||
|
||||
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
||||
|
||||
-130
@@ -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,68 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* Whether this calendar's events may have their times rewritten by a drag (#68).
|
||||
* Deliberately not [isEventTarget]: a managed event stays editable (reminders,
|
||||
* notes) yet must never move, since the next contacts sync would put it back.
|
||||
*/
|
||||
val CalendarSource.allowsEventMove: Boolean
|
||||
get() = canModifyContents && !isManaged
|
||||
|
||||
/** 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,64 @@
|
||||
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 }
|
||||
|
||||
/**
|
||||
* The failure a calendar list implies, or null when there is something to show.
|
||||
* Distinguishes "the device has no calendars" from "every one of them is
|
||||
* switched off" (#239) — both leave the grid empty, but only the second is
|
||||
* fixed from Settings → Calendars.
|
||||
*
|
||||
* The filter sheet's own hidden set is deliberately not folded in: emptying it
|
||||
* is a deliberate act taken moments earlier, in a sheet that is still one tap
|
||||
* away.
|
||||
*/
|
||||
fun calendarListFailure(calendars: List<CalendarSource>): FailureReason? = when {
|
||||
calendars.isEmpty() -> FailureReason.NoCalendarsConfigured
|
||||
calendars.none { it.isVisibleInSystem } -> FailureReason.AllCalendarsHidden
|
||||
else -> null
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cbrt
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
import de.jeanlucmakiola.calendula.domain.color.Oklch
|
||||
import de.jeanlucmakiola.calendula.domain.color.eventTone
|
||||
import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
||||
|
||||
/**
|
||||
* Curates an account's published event palette for the colour picker.
|
||||
@@ -18,24 +14,25 @@ import kotlin.math.sqrt
|
||||
* (#22).
|
||||
*
|
||||
* Crucially, curation runs against the colour the picker actually *paints*, not
|
||||
* the raw provider value. The picker softens every swatch through [pastelArgb]:
|
||||
* it pins lightness to a constant and caps saturation, so the raw palette's
|
||||
* lightness axis is invisible on screen. Two raw colours that look different —
|
||||
* a navy and a mid blue — paint as one swatch, and every neutral (black, the
|
||||
* grays, white) paints as the same pale tint. Judging distinctness in raw
|
||||
* space, as before, left near-identical painted swatches and stranded the
|
||||
* neutrals as a run of look-alike "pinks" at the end of the grid.
|
||||
* the raw provider value — and it gets that colour from the same [eventTone]
|
||||
* the picker calls, rather than from a copy of its shaping kept in step by hand.
|
||||
* Because a harmonised container pins lightness, the raw palette's lightness
|
||||
* axis is invisible on screen: two raw colours that look different — a navy and
|
||||
* a mid blue — paint as one swatch, and every neutral (black, the grays, white)
|
||||
* paints as the same grey. Judging distinctness in raw space, as before, left
|
||||
* near-identical painted swatches and stranded the neutrals as a run of
|
||||
* look-alike tints at the end of the grid.
|
||||
*
|
||||
* Three steps, all in painted space:
|
||||
* 1. Collapse swatches that paint identically to one (alphabetically-first key
|
||||
* wins, deterministically) — this folds aliases, dark/light shades of a
|
||||
* hue, and all the neutrals together.
|
||||
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out
|
||||
* neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are
|
||||
* then thinned to visually distinct colours: most vivid first, a colour is
|
||||
* kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every
|
||||
* colour already kept. Small palettes are already curated by their adapter
|
||||
* and pass through whole.
|
||||
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the neutral-origin
|
||||
* swatches — with lightness pinned, a grey source paints as plain grey, so
|
||||
* "has no hue left" is simply zero chroma — and are then thinned to visually
|
||||
* distinct colours: most vivid first, a colour is kept only when at least
|
||||
* [MIN_DISTANCE] away in Oklab from every colour already kept. Small
|
||||
* palettes are already curated by their adapter and pass through whole.
|
||||
* 3. The survivors are ordered like a rainbow — continuously by painted hue —
|
||||
* with the wheel cut at its single widest empty gap so the one unavoidable
|
||||
* seam lands in dead space and no hue family is torn across both ends.
|
||||
@@ -45,12 +42,12 @@ import kotlin.math.sqrt
|
||||
*/
|
||||
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||
val painted = sortedBy { it.key }
|
||||
.distinctBy { pastelArgb(it.argb) }
|
||||
.map { it to Lab.of(pastelArgb(it.argb)) }
|
||||
.distinctBy { paintedArgb(it.argb) }
|
||||
.map { it to oklchOf(paintedArgb(it.argb)) }
|
||||
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
|
||||
painted
|
||||
} else {
|
||||
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
|
||||
thin(painted.filter { (_, painted) -> painted.chroma > 0f })
|
||||
}
|
||||
return orderAroundWheel(kept).map { (option, _) -> option }
|
||||
}
|
||||
@@ -61,17 +58,17 @@ fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||
* instead of mid-family. Saturation breaks ties, vivid first.
|
||||
*/
|
||||
private fun orderAroundWheel(
|
||||
swatches: List<Pair<EventColorOption, Lab>>,
|
||||
): List<Pair<EventColorOption, Lab>> {
|
||||
swatches: List<Pair<EventColorOption, Oklch>>,
|
||||
): List<Pair<EventColorOption, Oklch>> {
|
||||
if (swatches.size < 2) return swatches
|
||||
val byHue = swatches.sortedWith(
|
||||
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }),
|
||||
compareBy({ (_, painted) -> painted.hue }, { (_, painted) -> -painted.chroma }),
|
||||
)
|
||||
// Split the wheel after the largest empty arc between neighbouring hues;
|
||||
// the default is the wrap gap (last hue back round to the first), i.e. the
|
||||
// familiar 0→360 order, and we only rotate away from it for a wider void.
|
||||
var cutAfter = byHue.lastIndex
|
||||
var widestGap = 360.0 - byHue.last().second.hue + byHue.first().second.hue
|
||||
var widestGap = 360f - byHue.last().second.hue + byHue.first().second.hue
|
||||
for (i in 0 until byHue.lastIndex) {
|
||||
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
|
||||
if (gap > widestGap) {
|
||||
@@ -84,102 +81,46 @@ private fun orderAroundWheel(
|
||||
|
||||
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
||||
private fun thin(
|
||||
swatches: List<Pair<EventColorOption, Lab>>,
|
||||
): List<Pair<EventColorOption, Lab>> {
|
||||
swatches: List<Pair<EventColorOption, Oklch>>,
|
||||
): List<Pair<EventColorOption, Oklch>> {
|
||||
val byVividness = swatches
|
||||
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
|
||||
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
|
||||
.sortedWith(
|
||||
compareByDescending<Pair<EventColorOption, Oklch>> { it.second.chroma }
|
||||
.thenBy { it.first.key },
|
||||
)
|
||||
val kept = mutableListOf<Pair<EventColorOption, Oklch>>()
|
||||
for (candidate in byVividness) {
|
||||
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
|
||||
if (kept.none { it.second.distanceTo(candidate.second) < MIN_DISTANCE }) kept += candidate
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
/**
|
||||
* The softening the colour picker paints over every swatch: keep the hue, scale
|
||||
* and clamp saturation into a gentle band, and pin value to a constant so
|
||||
* nothing screams and everything reads on the surface. Value is fixed here so
|
||||
* curation is theme-independent — only hue and saturation distinguish painted
|
||||
* swatches.
|
||||
*
|
||||
* This is a self-contained mirror of floret-kit's `pastelize` hue/saturation
|
||||
* shaping (`de.jeanlucmakiola.floret.components.pastelize`), with value pinned
|
||||
* rather than theme-picked. Curation must reason about the colour the picker
|
||||
* paints, so the two shapings have to agree: if floret's saturation band or
|
||||
* curve changes, update this in step.
|
||||
*/
|
||||
fun pastelArgb(rawArgb: Int): Int {
|
||||
val r = ((rawArgb shr 16) and 0xFF) / 255f
|
||||
val g = ((rawArgb shr 8) and 0xFF) / 255f
|
||||
val b = (rawArgb and 0xFF) / 255f
|
||||
val max = maxOf(r, g, b)
|
||||
val min = minOf(r, g, b)
|
||||
val delta = max - min
|
||||
val hue = when {
|
||||
delta == 0f -> 0f
|
||||
max == r -> 60f * (((g - b) / delta) % 6f)
|
||||
max == g -> 60f * (((b - r) / delta) + 2f)
|
||||
else -> 60f * (((r - g) / delta) + 4f)
|
||||
}.let { if (it < 0f) it + 360f else it }
|
||||
val sat = (if (max == 0f) 0f else delta / max) * 0.6f
|
||||
val s = sat.coerceIn(0.25f, 0.65f)
|
||||
val v = PASTEL_VALUE
|
||||
val c = v * s
|
||||
val x = c * (1f - abs((hue / 60f) % 2f - 1f))
|
||||
val m = v - c
|
||||
val (rr, gg, bb) = when {
|
||||
hue < 60f -> Triple(c, x, 0f)
|
||||
hue < 120f -> Triple(x, c, 0f)
|
||||
hue < 180f -> Triple(0f, c, x)
|
||||
hue < 240f -> Triple(0f, x, c)
|
||||
hue < 300f -> Triple(x, 0f, c)
|
||||
else -> Triple(c, 0f, x)
|
||||
}
|
||||
fun channel(value: Float) = ((value + m) * 255f).roundToInt().coerceIn(0, 255)
|
||||
return (0xFF shl 24) or (channel(rr) shl 16) or (channel(gg) shl 8) or channel(bb)
|
||||
}
|
||||
|
||||
/** Reference lightness for curation; the picker paints at this on dark surfaces. */
|
||||
private const val PASTEL_VALUE = 0.82f
|
||||
/** The colour the picker paints for [argb]; the light theme stands in as the
|
||||
* reference, since a harmonised container differs only in lightness by theme
|
||||
* and curation compares hue and chroma. */
|
||||
private fun paintedArgb(argb: Int): Int =
|
||||
eventTone(argb, dark = false, harmonise = true).container
|
||||
|
||||
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
|
||||
private const val CURATION_TRIGGER_SIZE = 36
|
||||
|
||||
/** Minimum CIE76 ΔE between surviving painted swatches. */
|
||||
private const val MIN_DELTA_E = 13.0
|
||||
/**
|
||||
* Minimum Oklab distance between surviving painted swatches. Painted colours all
|
||||
* share one lightness, so this is really a hue/chroma separation — far enough
|
||||
* apart that two swatches never read as the same colour in the grid.
|
||||
*/
|
||||
private const val MIN_DISTANCE = 0.025f
|
||||
|
||||
/**
|
||||
* Painted-chroma floor for oversized palettes: below this a swatch is a washed-
|
||||
* out tint — the neutrals and near-whites the saturation clamp muddies — so it
|
||||
* is dropped rather than shown as pale filler.
|
||||
* The palette entry closest to [argb], or null when the account publishes no
|
||||
* palette (its calendars then take a raw `EVENT_COLOR` instead).
|
||||
*
|
||||
* Used to land a colour that came from outside the account — an imported `.ics`
|
||||
* carries an arbitrary ARGB, while a palette calendar only accepts one of its
|
||||
* own keys. Distance is measured in Oklab, so the match is the one that looks
|
||||
* closest rather than the one that is closest in RGB coordinates.
|
||||
*/
|
||||
private const val PASTEL_CHROMA_FLOOR = 22.0
|
||||
|
||||
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
|
||||
private class Lab(val l: Double, val a: Double, val b: Double) {
|
||||
val chroma: Double get() = hypot(a, b)
|
||||
|
||||
/** Hue angle in degrees, 0–360, around the Lab a-b plane. */
|
||||
val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
|
||||
|
||||
fun deltaE(other: Lab): Double =
|
||||
sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
|
||||
|
||||
companion object {
|
||||
fun of(argb: Int): Lab {
|
||||
fun linear(shift: Int): Double {
|
||||
val c = ((argb shr shift) and 0xFF) / 255.0
|
||||
return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
|
||||
}
|
||||
val r = linear(16)
|
||||
val g = linear(8)
|
||||
val b = linear(0)
|
||||
val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
|
||||
val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
|
||||
fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0
|
||||
val fy = f(y)
|
||||
return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z)))
|
||||
}
|
||||
}
|
||||
fun List<EventColorOption>.nearestTo(argb: Int): EventColorOption? {
|
||||
val target = oklchOf(argb)
|
||||
return minByOrNull { oklchOf(it.argb).distanceTo(target) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.TimeZone
|
||||
|
||||
/** Lower values appear first in lists and get the upper visible lanes in grids. */
|
||||
fun EventInstance.displayPriority(zone: TimeZone): Int = when {
|
||||
isDeclined -> 3
|
||||
spansMultipleDays(zone) -> 0
|
||||
isAllDay -> 1
|
||||
else -> 2
|
||||
}
|
||||
|
||||
/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */
|
||||
fun eventDisplayComparator(zone: TimeZone): Comparator<EventInstance> =
|
||||
compareBy<EventInstance> { it.displayPriority(zone) }
|
||||
.thenBy { it.start }
|
||||
.thenBy { it.title }
|
||||
.thenBy { it.instanceId }
|
||||
@@ -0,0 +1,256 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** An event with the free text the search may have matched it on. */
|
||||
data class SearchCandidate(
|
||||
val event: EventInstance,
|
||||
val description: String? = null,
|
||||
/** Null when the event has none: [EventInstance.title] is then a display
|
||||
* placeholder, which the query must not catch on. */
|
||||
val title: String? = event.title,
|
||||
)
|
||||
|
||||
/** A matched run inside one of a hit's texts, for highlighting. */
|
||||
data class MatchSpan(val start: Int, val end: Int)
|
||||
|
||||
/** The part of a description a token matched, elided to what fits a row. */
|
||||
data class DescriptionSnippet(
|
||||
val text: String,
|
||||
val spans: List<MatchSpan>,
|
||||
)
|
||||
|
||||
/** One result: the event, plus where the query matched it. */
|
||||
data class SearchHit(
|
||||
val event: EventInstance,
|
||||
val titleSpans: List<MatchSpan> = emptyList(),
|
||||
val locationSpans: List<MatchSpan> = emptyList(),
|
||||
val descriptionSnippet: DescriptionSnippet? = null,
|
||||
/** Already over, so the row can say so. Finished-today counts. */
|
||||
val isPast: Boolean = false,
|
||||
)
|
||||
|
||||
/** One month's worth of hits, as the results list draws them under a header. */
|
||||
data class SearchMonth(
|
||||
val year: Int,
|
||||
/** 1–12, so the UI can build the month's first day for a localized label. */
|
||||
val monthNumber: Int,
|
||||
val hits: List<SearchHit>,
|
||||
)
|
||||
|
||||
/** A finished search: a calendar of hits, plus the description-only ones. */
|
||||
data class SearchResults(
|
||||
val months: List<SearchMonth>,
|
||||
val inDescriptions: List<SearchHit>,
|
||||
) {
|
||||
val isEmpty: Boolean get() = months.isEmpty() && inDescriptions.isEmpty()
|
||||
|
||||
/** Every hit, whichever section it sits in — for select-all and delete. */
|
||||
val allHits: List<SearchHit> get() = months.flatMap { it.hits } + inDescriptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Turning a typed query into the results list.
|
||||
*
|
||||
* An event has to carry *every* whitespace-separated token, each in any of
|
||||
* title / location / description; case is folded in Kotlin rather than by SQL's
|
||||
* ASCII-only `LIKE`, so "ärzte" finds "Ärzte".
|
||||
*
|
||||
* Results are a calendar, not a ranking: months from the current one, then
|
||||
* forwards, then backwards, and inside a month what is still to come before
|
||||
* what has passed. The day is the boundary, not the moment. A hit the query
|
||||
* only reached through its description is held back in
|
||||
* [SearchResults.inDescriptions] rather than dated among the real ones.
|
||||
*/
|
||||
object EventSearch {
|
||||
|
||||
/** Characters of description kept around the match in a row's snippet. */
|
||||
private const val SNIPPET_LENGTH = 96
|
||||
|
||||
/** Characters of lead-in shown before the match, when there is room. */
|
||||
private const val SNIPPET_LEAD = 24
|
||||
|
||||
private val WHITESPACE = Regex("\\s+")
|
||||
|
||||
/** Shared with the data layer so its SQL pre-filter tokenises identically. */
|
||||
fun tokenize(query: String): List<String> =
|
||||
query.trim().split(WHITESPACE).filter { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* Search [candidates] for [query], dropping any that don't carry every token.
|
||||
* [now] marks a hit as over, [todayStart] is where it stops being current.
|
||||
* [zone] places each event on the calendar through the span rule, so an
|
||||
* all-day event keeps its own day whichever side of UTC we are on (#82).
|
||||
*/
|
||||
fun search(
|
||||
candidates: List<SearchCandidate>,
|
||||
query: String,
|
||||
now: Instant,
|
||||
todayStart: Instant,
|
||||
zone: TimeZone,
|
||||
): SearchResults {
|
||||
val tokens = tokenize(query)
|
||||
if (tokens.isEmpty()) return SearchResults(emptyList(), emptyList())
|
||||
|
||||
val today = todayStart.toLocalDateTime(zone).date
|
||||
val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) }
|
||||
val upcoming = { m: Matched -> m.event.spanLastDay(zone) >= today }
|
||||
val order = compareBy<Matched> { if (upcoming(it)) 0 else 1 }
|
||||
.thenComparator { a, b ->
|
||||
// What is left reads forwards; what is behind us reads backwards
|
||||
// from today, nearest first.
|
||||
if (upcoming(a)) {
|
||||
a.event.dayStart(zone).compareTo(b.event.dayStart(zone))
|
||||
} else {
|
||||
b.event.dayStart(zone).compareTo(a.event.dayStart(zone))
|
||||
}
|
||||
}
|
||||
|
||||
val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly }
|
||||
return SearchResults(
|
||||
months = groupByMonth(dated, zone, todayStart),
|
||||
inDescriptions = inDescriptions.map { it.hit },
|
||||
)
|
||||
}
|
||||
|
||||
/** Months from the current one, then forwards, then backwards. */
|
||||
private fun groupByMonth(
|
||||
dated: List<Matched>,
|
||||
zone: TimeZone,
|
||||
todayStart: Instant,
|
||||
): List<SearchMonth> {
|
||||
val currentKey = monthKey(todayStart.toLocalDateTime(zone).date)
|
||||
return dated
|
||||
.groupBy { monthKey(it.event.spanFirstDay(zone)) }
|
||||
.entries
|
||||
.sortedWith(
|
||||
compareBy<Map.Entry<Int, List<Matched>>> {
|
||||
when {
|
||||
it.key == currentKey -> 0
|
||||
it.key > currentKey -> 1
|
||||
else -> 2
|
||||
}
|
||||
}.thenComparator { a, b ->
|
||||
if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key)
|
||||
},
|
||||
)
|
||||
.map { (key, entries) ->
|
||||
SearchMonth(
|
||||
year = key / 12,
|
||||
monthNumber = key % 12 + 1,
|
||||
hits = entries.map { it.hit },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Year and month as one comparable number. */
|
||||
private fun monthKey(date: LocalDate): Int = date.year * 12 + (date.month.number - 1)
|
||||
|
||||
private fun match(
|
||||
candidate: SearchCandidate,
|
||||
tokens: List<String>,
|
||||
now: Instant,
|
||||
zone: TimeZone,
|
||||
): Matched? {
|
||||
val title = candidate.title?.takeIf { it.isNotBlank() }
|
||||
val location = candidate.event.location?.takeIf { it.isNotBlank() }
|
||||
// Collapsed so a multi-line description matches the way it is drawn.
|
||||
val description = candidate.description
|
||||
?.replace(WHITESPACE, " ")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
// Mid-word counts: "termin" has to keep finding "Zahnarzttermin".
|
||||
val present = { token: String ->
|
||||
title?.contains(token, ignoreCase = true) == true ||
|
||||
location?.contains(token, ignoreCase = true) == true ||
|
||||
description?.contains(token, ignoreCase = true) == true
|
||||
}
|
||||
if (!tokens.all(present)) return null
|
||||
|
||||
val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty()
|
||||
val titleSpans = title?.let { spansIn(it, tokens) }.orEmpty()
|
||||
val locationSpans = location?.let { spansIn(it, tokens) }.orEmpty()
|
||||
return Matched(
|
||||
hit = SearchHit(
|
||||
event = candidate.event,
|
||||
titleSpans = titleSpans,
|
||||
locationSpans = locationSpans,
|
||||
descriptionSnippet = description
|
||||
?.takeIf { descriptionSpans.isNotEmpty() }
|
||||
?.let { snippet(it, descriptionSpans) },
|
||||
isPast = candidate.event.dayEnd(zone) < now,
|
||||
),
|
||||
// Nothing in the name or the place caught the query: it is only here
|
||||
// for something in its notes.
|
||||
descriptionOnly = titleSpans.isEmpty() && locationSpans.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Every occurrence of every token in [text], merged where they overlap. */
|
||||
private fun spansIn(text: String, tokens: List<String>): List<MatchSpan> {
|
||||
val raw = mutableListOf<MatchSpan>()
|
||||
for (token in tokens) {
|
||||
var index = text.indexOf(token, startIndex = 0, ignoreCase = true)
|
||||
while (index >= 0) {
|
||||
raw += MatchSpan(index, index + token.length)
|
||||
index = text.indexOf(token, startIndex = index + 1, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
if (raw.isEmpty()) return emptyList()
|
||||
val sorted = raw.sortedBy { it.start }
|
||||
val merged = mutableListOf(sorted.first())
|
||||
for (span in sorted.drop(1)) {
|
||||
val last = merged.last()
|
||||
if (span.start <= last.end) {
|
||||
merged[merged.lastIndex] = MatchSpan(last.start, maxOf(last.end, span.end))
|
||||
} else {
|
||||
merged += span
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* A window of [description] around its first match, elided at both ends,
|
||||
* with the spans rebased onto it and any falling outside dropped.
|
||||
*/
|
||||
private fun snippet(description: String, spans: List<MatchSpan>): DescriptionSnippet {
|
||||
if (description.length <= SNIPPET_LENGTH) {
|
||||
return DescriptionSnippet(description, spans)
|
||||
}
|
||||
val first = spans.first().start
|
||||
val start = (first - SNIPPET_LEAD).coerceIn(0, (description.length - SNIPPET_LENGTH))
|
||||
val end = (start + SNIPPET_LENGTH).coerceAtMost(description.length)
|
||||
val prefix = if (start > 0) "…" else ""
|
||||
val suffix = if (end < description.length) "…" else ""
|
||||
val text = prefix + description.substring(start, end) + suffix
|
||||
val shift = prefix.length - start
|
||||
val rebased = spans
|
||||
.filter { it.start >= start && it.end <= end }
|
||||
.map { MatchSpan(it.start + shift, it.end + shift) }
|
||||
return DescriptionSnippet(text, rebased)
|
||||
}
|
||||
|
||||
private data class Matched(val hit: SearchHit, val descriptionOnly: Boolean) {
|
||||
val event: EventInstance get() = hit.event
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's first day as an instant in [zone]. An all-day event sits at
|
||||
* UTC midnight, so its raw start would rank it on a day it isn't drawn on.
|
||||
*/
|
||||
private fun EventInstance.dayStart(zone: TimeZone): Instant =
|
||||
if (isAllDay) spanFirstDay(zone).atStartOfDayIn(zone) else start
|
||||
|
||||
/** The moment the event's last day is over in [zone], exclusive. */
|
||||
private fun EventInstance.dayEnd(zone: TimeZone): Instant =
|
||||
if (isAllDay) spanLastDay(zone).plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) else end
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The zone this form's wall-clock times mean, matching what the data layer
|
||||
* resolves them in at write time: the form's own pinned zone, else [deviceZone].
|
||||
*/
|
||||
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
|
||||
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
|
||||
|
||||
/**
|
||||
* The form moved so it starts at [newStart], keeping its **instant** duration —
|
||||
* a recurring event's length travels to the provider as `DURATION`, so keeping
|
||||
* wall clock instead would rewrite the series' length across a DST boundary.
|
||||
*
|
||||
* All-day events are date-anchored — use [shiftedByDays]; this returns them
|
||||
* untouched.
|
||||
*/
|
||||
fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
|
||||
if (isAllDay) return this
|
||||
val zone = resolvedZone(deviceZone)
|
||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
||||
return copy(
|
||||
start = newStart.toLocalDateTime(zone),
|
||||
end = (newStart + span).toLocalDateTime(zone),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The form moved [days] calendar days, keeping its time of day and its span.
|
||||
* All-day events are pure date arithmetic; a timed one preserves its **instant**
|
||||
* duration, for the same reason [shiftedTo] does.
|
||||
*/
|
||||
fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
|
||||
if (days == 0) return this
|
||||
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
|
||||
if (isAllDay) {
|
||||
return copy(
|
||||
start = newStart,
|
||||
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time),
|
||||
)
|
||||
}
|
||||
val zone = resolvedZone(deviceZone)
|
||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
||||
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
|
||||
}
|
||||
@@ -15,7 +15,9 @@ import kotlin.time.Instant
|
||||
* calendar this way to create a new event, passing the fields as
|
||||
* [android.provider.CalendarContract] extras. Any field the intent omits falls
|
||||
* back to the same defaults the in-app "new event" uses — a timed start at the
|
||||
* next full hour and a one-hour duration. [EventForm.calendarId] is left null so
|
||||
* next full hour, and a placeholder hour for a missing end that the form then
|
||||
* stretches to the default-duration setting (the intent is opened as
|
||||
* `ImportSource.InsertOpenEnded`, #54). [EventForm.calendarId] is left null so
|
||||
* it resolves to the last-used / first-writable calendar, exactly like the
|
||||
* `.ics` single-event and plain new-event paths.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
data class CalendarSource(
|
||||
@@ -8,6 +12,11 @@ data class CalendarSource(
|
||||
val accountName: String,
|
||||
val accountType: String,
|
||||
val color: Int,
|
||||
/**
|
||||
* The system's `Calendars.VISIBLE` flag — the single visibility model,
|
||||
* deciding both what Calendula shows and whether this calendar plans
|
||||
* reminders (#75). The drawer's filter sheet is a separate in-app declutter.
|
||||
*/
|
||||
val isVisibleInSystem: Boolean,
|
||||
/**
|
||||
* Whether events in this calendar can be created/edited/deleted
|
||||
@@ -34,6 +43,13 @@ data class CalendarSource(
|
||||
* even after a backup restore clears the app's stored ids.
|
||||
*/
|
||||
val isManaged: Boolean = false,
|
||||
/**
|
||||
* Whether the provider keeps this calendar's events on the device
|
||||
* (`Calendars.SYNC_EVENTS`), independent of [isVisibleInSystem]. Says
|
||||
* nothing about device-local calendars, which can hold events with it off.
|
||||
* Read for the "not synced" row label (#76).
|
||||
*/
|
||||
val syncsEvents: Boolean = true,
|
||||
)
|
||||
|
||||
data class EventInstance(
|
||||
@@ -46,8 +62,41 @@ data class EventInstance(
|
||||
val isAllDay: Boolean,
|
||||
val color: Int,
|
||||
val location: String?,
|
||||
/**
|
||||
* Only search results, which read the series master, fill this in — the
|
||||
* Instances query already yields one row per occurrence.
|
||||
*/
|
||||
val isRecurring: Boolean = false,
|
||||
/**
|
||||
* This device user's own answer to the invitation, as far as the grids care
|
||||
* (#180, #230).
|
||||
*/
|
||||
val response: EventResponse = EventResponse.Going,
|
||||
)
|
||||
|
||||
/**
|
||||
* How this device user stands towards an event's invitation
|
||||
* (`Events.SELF_ATTENDEE_STATUS`), reduced to the three cases the calendar
|
||||
* surfaces draw differently (#230).
|
||||
*/
|
||||
enum class EventResponse {
|
||||
/** Your own event, or one you accepted — including a tentative "maybe". */
|
||||
Going,
|
||||
|
||||
/** Invited, no answer given yet: drawn as an outline so it reads as still open. */
|
||||
Invited,
|
||||
|
||||
/**
|
||||
* You answered "no". The event stays on the calendar — it is still an
|
||||
* appointment someone expects an answer about — but every surface strikes it
|
||||
* through, and it plans no reminders (#180).
|
||||
*/
|
||||
Declined,
|
||||
}
|
||||
|
||||
/** Shorthand for the declined case, which most surfaces test on its own (#180). */
|
||||
val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined
|
||||
|
||||
/**
|
||||
* Whether this event has finished relative to [now] — its end is at or before
|
||||
* the current instant. An in-progress event (already started but not yet ended)
|
||||
@@ -56,6 +105,31 @@ data class EventInstance(
|
||||
*/
|
||||
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||
|
||||
/**
|
||||
* The zone this event's calendar dates live in: the device [zone] for timed
|
||||
* events, UTC for all-day ones, whose midnights would otherwise shift day
|
||||
* boundaries (#65, #82). Every surface naming an all-day date goes through here.
|
||||
*/
|
||||
fun EventInstance.dateZone(zone: TimeZone): TimeZone =
|
||||
if (isAllDay) TimeZone.UTC else zone
|
||||
|
||||
/** The first calendar day this event occupies. */
|
||||
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
|
||||
start.toLocalDateTime(dateZone(zone)).date
|
||||
|
||||
/**
|
||||
* The last calendar day this event occupies. An event ending exactly at midnight
|
||||
* does not reach into that day, so resolve just before [EventInstance.end].
|
||||
*/
|
||||
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
||||
val lastInstant = if (end > start) end - 1.milliseconds else start
|
||||
return lastInstant.toLocalDateTime(dateZone(zone)).date
|
||||
}
|
||||
|
||||
/** Whether this event occupies more than one calendar day in [zone]. */
|
||||
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
||||
spanFirstDay(zone) != spanLastDay(zone)
|
||||
|
||||
data class EventDetail(
|
||||
val instance: EventInstance,
|
||||
val description: String?,
|
||||
@@ -83,6 +157,12 @@ data class EventDetail(
|
||||
val eventColor: Int? = null,
|
||||
/** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */
|
||||
val eventColorKey: String? = null,
|
||||
/**
|
||||
* True when this row is a modified occurrence of a series (`ORIGINAL_ID` is
|
||||
* set) rather than a master, so a reschedule takes the plain whole-row path
|
||||
* whatever [rrule] a sync adapter left on it.
|
||||
*/
|
||||
val isException: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -172,6 +252,14 @@ enum class RecurringWriteScope {
|
||||
enum class FailureReason {
|
||||
PermissionRevoked,
|
||||
NoCalendarsConfigured,
|
||||
AllCalendarsHidden,
|
||||
|
||||
/**
|
||||
* Calendars exist and are switched on, but none can receive an event: every
|
||||
* one is read-only, app-managed, or not synced to this device. Distinct from
|
||||
* [AllCalendarsHidden], which a visibility switch fixes.
|
||||
*/
|
||||
NoImportTarget,
|
||||
ProviderUnavailable,
|
||||
EventNotFound,
|
||||
Unknown,
|
||||
|
||||
@@ -138,7 +138,7 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()):
|
||||
}
|
||||
}
|
||||
|
||||
private val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
||||
internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
||||
DayOfWeek.MONDAY to "MO",
|
||||
DayOfWeek.TUESDAY to "TU",
|
||||
DayOfWeek.WEDNESDAY to "WE",
|
||||
|
||||
@@ -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,48 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* [rrule] re-anchored from an occurrence on [oldStart] to one on [newStart].
|
||||
*
|
||||
* `Events.RRULE` is written verbatim while DTSTART moves, so `FREQ=WEEKLY;BYDAY=MO`
|
||||
* would keep naming Monday after the anchor became a Wednesday and the series
|
||||
* would not move at all — `BYDAY` is re-derived from [newStart].
|
||||
*
|
||||
* Only weekly `BYDAY` is realigned, and only for a whole-day move: the rule has
|
||||
* to agree with the series *anchor*, and weekday arithmetic is the only kind
|
||||
* that survives the same wall-clock shift unchanged. Everything else returns
|
||||
* null, as do rules one moved occurrence can't resolve (`BYDAY=MO,WE`, `2TH`,
|
||||
* `BYSETPOS`). The accepted parts are a subset of [parseSimpleRecurrence], so
|
||||
* anything realignable is also a rule [problems] can check the `UNTIL` of.
|
||||
*/
|
||||
fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
|
||||
if (oldStart == newStart) return rrule
|
||||
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else ""
|
||||
val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }
|
||||
if (parts.isEmpty()) return null
|
||||
var weekly = false
|
||||
val rebuilt = parts.map { part ->
|
||||
val eq = part.indexOf('=')
|
||||
if (eq <= 0) return null
|
||||
val key = part.substring(0, eq).uppercase()
|
||||
val value = part.substring(eq + 1).trim()
|
||||
when (key) {
|
||||
"FREQ" -> {
|
||||
weekly = value.equals("WEEKLY", ignoreCase = true)
|
||||
part
|
||||
}
|
||||
"BYDAY" -> {
|
||||
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
|
||||
if (!value.equals(old, ignoreCase = true)) return null
|
||||
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
|
||||
}
|
||||
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
// BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence.
|
||||
if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null
|
||||
if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null
|
||||
return prefix + rebuilt.joinToString(";")
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package de.jeanlucmakiola.calendula.domain.color
|
||||
|
||||
/**
|
||||
* The colours one calendar's identity resolves to in one theme.
|
||||
*
|
||||
* The app paints a calendar's colour in two structurally different places, and
|
||||
* they want opposite things:
|
||||
*
|
||||
* - a **[container]** sits behind text (week and day blocks, month bars, widget
|
||||
* rows, picker swatches), so it has to contrast with its own ink;
|
||||
* - an **[accent]** is a mark on an ordinary app surface (day dots, agenda and
|
||||
* search stripes, calendar icon tints, the detail header), so it has to
|
||||
* contrast with the *surface* instead.
|
||||
*
|
||||
* Painting both from one colour is what made this hard to get right: deep enough
|
||||
* to carry white text is too dark to see as a dot on a dark surface, and light
|
||||
* enough to show up there is too pale to carry white text. Splitting them lets
|
||||
* each be pinned to the lightness its job needs, while hue and chroma — the parts
|
||||
* that actually say *which calendar this is* — stay shared, so the two roles
|
||||
* still read as the same colour.
|
||||
*/
|
||||
data class EventTone(
|
||||
val container: Int,
|
||||
val onContainer: Int,
|
||||
val accent: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolve [rawArgb] for the current theme.
|
||||
*
|
||||
* With [harmonise] on, hue and chroma are kept and lightness is re-pinned per
|
||||
* role, which is what makes the ink predictable: every container lands at the
|
||||
* same perceptual lightness, so one ink colour serves every hue at ≥ 6:1. With
|
||||
* it off the provider's colour is painted verbatim — the sync source's own look,
|
||||
* as DAVx5/CalDAV users expect — and the ink is then chosen per colour, since
|
||||
* nothing constrains what the provider sends.
|
||||
*/
|
||||
fun eventTone(rawArgb: Int, dark: Boolean, harmonise: Boolean): EventTone {
|
||||
val opaque = rawArgb or 0xFF000000.toInt()
|
||||
val raw = oklchOf(opaque)
|
||||
if (!harmonise) {
|
||||
return EventTone(
|
||||
container = opaque,
|
||||
onContainer = inkFor(raw.lightness),
|
||||
accent = opaque,
|
||||
)
|
||||
}
|
||||
// A near-grey source has no hue worth keeping, so it stays grey rather than
|
||||
// being pushed to an arbitrary one; everything else is held inside a band
|
||||
// that keeps calendars apart without going neon.
|
||||
val chroma = if (raw.chroma < GREY_CHROMA) 0f else raw.chroma.coerceIn(MIN_CHROMA, MAX_CHROMA)
|
||||
// Two poles rather than one. Forcing every hue deep is what turned the warm,
|
||||
// naturally light ones muddy — a dark orange is brown and a dark yellow is
|
||||
// olive, which is a fact about those hues, not a tuning miss. So a colour is
|
||||
// sent to whichever pole it already sits nearer: most land deep and carry
|
||||
// white ink, while genuinely light sources (yellows, creams, pale tints)
|
||||
// stay light and carry dark ink, keeping the character that made them
|
||||
// recognisable. Either way the colour is pulled clear of the middle, where
|
||||
// neither ink reads well — which was the original fault.
|
||||
val staysLight = raw.lightness >= LIGHT_POLE_THRESHOLD
|
||||
val containerLightness = when {
|
||||
staysLight && dark -> LIGHT_CONTAINER_LIGHTNESS_DARK
|
||||
staysLight -> LIGHT_CONTAINER_LIGHTNESS_LIGHT
|
||||
dark -> CONTAINER_LIGHTNESS_DARK
|
||||
else -> CONTAINER_LIGHTNESS_LIGHT
|
||||
}
|
||||
// The accent takes no pole: it is a mark on the app's surface, so it has to
|
||||
// contrast with that surface whichever way its container went.
|
||||
val accentLightness = if (dark) ACCENT_LIGHTNESS_DARK else ACCENT_LIGHTNESS_LIGHT
|
||||
return EventTone(
|
||||
container = Oklch(containerLightness, chroma, raw.hue).toArgb(),
|
||||
onContainer = inkFor(containerLightness),
|
||||
accent = Oklch(accentLightness, chroma, raw.hue).toArgb(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* White or black ink for a background of the given perceptual [lightness].
|
||||
*
|
||||
* Derived rather than hardcoded so it stays right for raw provider colours,
|
||||
* where lightness is whatever the sync source sent. For harmonised containers it
|
||||
* is constant by construction — that is the point of pinning the lightness.
|
||||
*/
|
||||
fun inkFor(lightness: Float): Int =
|
||||
if (lightness < INK_FLIP_LIGHTNESS) 0xFFFFFFFF.toInt() else 0xFF000000.toInt()
|
||||
|
||||
/**
|
||||
* Lightness at which white ink overtakes black. Sits above the midpoint because
|
||||
* lightness is perceptual: a colour has to be distinctly light before black wins.
|
||||
*/
|
||||
const val INK_FLIP_LIGHTNESS = 0.62f
|
||||
|
||||
/**
|
||||
* Raw lightness at or above which a colour keeps its light character instead of
|
||||
* being pushed deep. Set high enough that oranges and warm reds still go deep —
|
||||
* a burnt orange reads as orange, where a dark yellow does not read as yellow —
|
||||
* so only the genuinely pale sources take the light pole.
|
||||
*/
|
||||
const val LIGHT_POLE_THRESHOLD = 0.72f
|
||||
|
||||
/** Container lightness: deep enough that white ink clears 5.5:1 on every hue. */
|
||||
const val CONTAINER_LIGHTNESS_LIGHT = 0.45f
|
||||
|
||||
/** Same in dark mode, a shade lighter so a block separates from the surface. */
|
||||
const val CONTAINER_LIGHTNESS_DARK = 0.48f
|
||||
|
||||
/** Light-pole container: pale enough that dark ink clears 11:1 on every hue. */
|
||||
const val LIGHT_CONTAINER_LIGHTNESS_LIGHT = 0.88f
|
||||
|
||||
/** Same in dark mode, held down a little so a pale block doesn't glare. */
|
||||
const val LIGHT_CONTAINER_LIGHTNESS_DARK = 0.84f
|
||||
|
||||
/** Accent lightness: dark enough to read as a mark on a pale surface. */
|
||||
const val ACCENT_LIGHTNESS_LIGHT = 0.55f
|
||||
|
||||
/** Same against a dark surface, where the mark has to be the light one. */
|
||||
const val ACCENT_LIGHTNESS_DARK = 0.78f
|
||||
|
||||
/** Below this chroma a colour counts as grey and keeps no hue. */
|
||||
const val GREY_CHROMA = 0.02f
|
||||
|
||||
/** Chroma band for harmonised colours: distinct, but never electric. */
|
||||
const val MIN_CHROMA = 0.07f
|
||||
const val MAX_CHROMA = 0.16f
|
||||
@@ -0,0 +1,119 @@
|
||||
package de.jeanlucmakiola.calendula.domain.color
|
||||
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cbrt
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* A colour in Oklch — Oklab's cylindrical form: perceptual [lightness], [chroma]
|
||||
* (colourfulness) and [hue] in degrees.
|
||||
*
|
||||
* The point of using it over HSV is that its lightness axis matches what the eye
|
||||
* calls brightness. HSV's "value" does not: at a single pinned value the hues
|
||||
* spread across relative luminance 0.10 (indigo) to 0.45 (yellow), which is why
|
||||
* pinning value produced fills that needed different ink per hue. Pin Oklch
|
||||
* lightness instead and every hue lands at the same apparent brightness, so one
|
||||
* ink serves all of them.
|
||||
*
|
||||
* [lightness] runs 0 (black) to 1 (white); [chroma] is 0 (grey) to about 0.32 at
|
||||
* the sRGB limit.
|
||||
*/
|
||||
data class Oklch(val lightness: Float, val chroma: Float, val hue: Float) {
|
||||
|
||||
/**
|
||||
* Perceptual distance to [other] — plain Euclidean in Oklab, which is what
|
||||
* Oklab is built for (unlike CIE Lab, where CIE76 is known to misjudge
|
||||
* saturated blues).
|
||||
*/
|
||||
fun distanceTo(other: Oklch): Float {
|
||||
val (a1, b1) = chroma * cosDeg(hue) to chroma * sinDeg(hue)
|
||||
val (a2, b2) = other.chroma * cosDeg(other.hue) to other.chroma * sinDeg(other.hue)
|
||||
val dl = (lightness - other.lightness).toDouble()
|
||||
return sqrt(dl * dl + (a1 - a2).pow(2) + (b1 - b2).pow(2)).toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
/** Read [argb]'s opaque colour as Oklch. */
|
||||
fun oklchOf(argb: Int): Oklch {
|
||||
val r = toLinear(((argb shr 16) and 0xFF) / 255.0)
|
||||
val g = toLinear(((argb shr 8) and 0xFF) / 255.0)
|
||||
val b = toLinear((argb and 0xFF) / 255.0)
|
||||
|
||||
val l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
|
||||
val m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
|
||||
val s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
|
||||
|
||||
val lightness = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s
|
||||
val aAxis = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s
|
||||
val bAxis = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
|
||||
|
||||
val hue = (Math.toDegrees(atan2(bAxis, aAxis)) + 360.0) % 360.0
|
||||
return Oklch(lightness.toFloat(), hypot(aAxis, bAxis).toFloat(), hue.toFloat())
|
||||
}
|
||||
|
||||
/**
|
||||
* The opaque sRGB colour for this Oklch, gamut-mapped: most of the Oklch cylinder
|
||||
* falls outside sRGB, so a colour that does not fit keeps its lightness and hue
|
||||
* and gives up chroma until it does. Holding lightness is what matters here —
|
||||
* it's the axis the contrast guarantees rest on.
|
||||
*/
|
||||
fun Oklch.toArgb(): Int {
|
||||
val fitted = if (inSrgb(lightness, chroma, hue)) {
|
||||
chroma
|
||||
} else {
|
||||
var low = 0f
|
||||
var high = chroma
|
||||
repeat(GAMUT_STEPS) {
|
||||
val mid = (low + high) / 2f
|
||||
if (inSrgb(lightness, mid, hue)) low = mid else high = mid
|
||||
}
|
||||
low
|
||||
}
|
||||
val (r, g, b) = linearSrgbOf(lightness, fitted, hue)
|
||||
return 0xFF shl 24 or
|
||||
(channel(r) shl 16) or
|
||||
(channel(g) shl 8) or
|
||||
channel(b)
|
||||
}
|
||||
|
||||
private fun linearSrgbOf(lightness: Float, chroma: Float, hue: Float): Triple<Double, Double, Double> {
|
||||
val a = chroma * cosDeg(hue)
|
||||
val b = chroma * sinDeg(hue)
|
||||
val l = (lightness + 0.3963377774 * a + 0.2158037573 * b).pow(3)
|
||||
val m = (lightness - 0.1055613458 * a - 0.0638541728 * b).pow(3)
|
||||
val s = (lightness - 0.0894841775 * a - 1.2914855480 * b).pow(3)
|
||||
return Triple(
|
||||
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
|
||||
)
|
||||
}
|
||||
|
||||
private fun inSrgb(lightness: Float, chroma: Float, hue: Float): Boolean {
|
||||
val (r, g, b) = linearSrgbOf(lightness, chroma, hue)
|
||||
return r in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON) &&
|
||||
g in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON) &&
|
||||
b in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON)
|
||||
}
|
||||
|
||||
private fun channel(linear: Double): Int =
|
||||
(toSrgb(linear.coerceIn(0.0, 1.0)) * 255.0).toInt().coerceIn(0, 255)
|
||||
|
||||
private fun toLinear(c: Double): Double =
|
||||
if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
|
||||
|
||||
private fun toSrgb(c: Double): Double =
|
||||
if (c <= 0.0031308) 12.92 * c else 1.055 * c.pow(1.0 / 2.4) - 0.055
|
||||
|
||||
private fun cosDeg(deg: Float) = cos(Math.toRadians(deg.toDouble()))
|
||||
private fun sinDeg(deg: Float) = sin(Math.toRadians(deg.toDouble()))
|
||||
|
||||
/** Bisection steps when pulling an out-of-gamut colour back into sRGB. */
|
||||
private const val GAMUT_STEPS = 24
|
||||
|
||||
/** Slack for the gamut test, so rounding at the boundary doesn't reject a fit. */
|
||||
private const val GAMUT_EPSILON = 1e-4
|
||||
@@ -0,0 +1,80 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
/** Opaque alpha, forced onto any colour that arrives without one. */
|
||||
private const val OPAQUE_ALPHA = 0xFF000000.toInt()
|
||||
|
||||
/**
|
||||
* Resolve a colour value out of a foreign `.ics` to an opaque ARGB int.
|
||||
*
|
||||
* Three encodings are in the wild and all appear in the files Calendula is
|
||||
* asked to import:
|
||||
* - a CSS3 colour name, which is what RFC 7986 `COLOR` is defined as
|
||||
* (`COLOR:tomato`);
|
||||
* - a raw signed Android colour int, which the Simple Calendar / Fossify
|
||||
* family writes in its `X-…-COLOR` extensions (`X-FOSSIFY-EVENT-COLOR:-2818048`);
|
||||
* - `#rrggbb` / `#aarrggbb` hex, used by assorted other producers.
|
||||
*
|
||||
* Anything else — including the literal `null` Fossify emits when its calendar
|
||||
* lookup misses — resolves to null.
|
||||
*/
|
||||
fun parseIcsColorValue(raw: String?): Int? {
|
||||
val value = raw?.trim().orEmpty()
|
||||
if (value.isEmpty()) return null
|
||||
|
||||
if (value.startsWith("#")) {
|
||||
val hex = value.substring(1)
|
||||
val parsed = hex.toLongOrNull(16)?.toInt() ?: return null
|
||||
return when (hex.length) {
|
||||
6 -> parsed or OPAQUE_ALPHA
|
||||
8 -> parsed
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
value.toIntOrNull()?.let { return if (it ushr 24 == 0) it or OPAQUE_ALPHA else it }
|
||||
return cssColors[value.lowercase()]
|
||||
}
|
||||
|
||||
/** The CSS3 extended colour keywords, the value space of RFC 7986 `COLOR`. */
|
||||
private val cssColors: Map<String, Int> by lazy {
|
||||
CSS3_TABLE.split(' ').associate { entry ->
|
||||
val name = entry.substringBefore('=')
|
||||
name to (entry.substringAfter('=').toInt(16) or OPAQUE_ALPHA)
|
||||
}
|
||||
}
|
||||
|
||||
private const val CSS3_TABLE =
|
||||
"aliceblue=f0f8ff antiquewhite=faebd7 aqua=00ffff aquamarine=7fffd4 " +
|
||||
"azure=f0ffff beige=f5f5dc bisque=ffe4c4 black=000000 blanchedalmond=ffebcd " +
|
||||
"blue=0000ff blueviolet=8a2be2 brown=a52a2a burlywood=deb887 cadetblue=5f9ea0 " +
|
||||
"chartreuse=7fff00 chocolate=d2691e coral=ff7f50 cornflowerblue=6495ed " +
|
||||
"cornsilk=fff8dc crimson=dc143c cyan=00ffff darkblue=00008b darkcyan=008b8b " +
|
||||
"darkgoldenrod=b8860b darkgray=a9a9a9 darkgreen=006400 darkgrey=a9a9a9 " +
|
||||
"darkkhaki=bdb76b darkmagenta=8b008b darkolivegreen=556b2f darkorange=ff8c00 " +
|
||||
"darkorchid=9932cc darkred=8b0000 darksalmon=e9967a darkseagreen=8fbc8f " +
|
||||
"darkslateblue=483d8b darkslategray=2f4f4f darkslategrey=2f4f4f darkturquoise=00ced1 " +
|
||||
"darkviolet=9400d3 deeppink=ff1493 deepskyblue=00bfff dimgray=696969 " +
|
||||
"dimgrey=696969 dodgerblue=1e90ff firebrick=b22222 floralwhite=fffaf0 " +
|
||||
"forestgreen=228b22 fuchsia=ff00ff gainsboro=dcdcdc ghostwhite=f8f8ff " +
|
||||
"gold=ffd700 goldenrod=daa520 gray=808080 green=008000 greenyellow=adff2f " +
|
||||
"grey=808080 honeydew=f0fff0 hotpink=ff69b4 indianred=cd5c5c indigo=4b0082 " +
|
||||
"ivory=fffff0 khaki=f0e68c lavender=e6e6fa lavenderblush=fff0f5 lawngreen=7cfc00 " +
|
||||
"lemonchiffon=fffacd lightblue=add8e6 lightcoral=f08080 lightcyan=e0ffff " +
|
||||
"lightgoldenrodyellow=fafad2 lightgray=d3d3d3 lightgreen=90ee90 lightgrey=d3d3d3 " +
|
||||
"lightpink=ffb6c1 lightsalmon=ffa07a lightseagreen=20b2aa lightskyblue=87cefa " +
|
||||
"lightslategray=778899 lightslategrey=778899 lightsteelblue=b0c4de " +
|
||||
"lightyellow=ffffe0 lime=00ff00 limegreen=32cd32 linen=faf0e6 magenta=ff00ff " +
|
||||
"maroon=800000 mediumaquamarine=66cdaa mediumblue=0000cd mediumorchid=ba55d3 " +
|
||||
"mediumpurple=9370db mediumseagreen=3cb371 mediumslateblue=7b68ee " +
|
||||
"mediumspringgreen=00fa9a mediumturquoise=48d1cc mediumvioletred=c71585 " +
|
||||
"midnightblue=191970 mintcream=f5fffa mistyrose=ffe4e1 moccasin=ffe4b5 " +
|
||||
"navajowhite=ffdead navy=000080 oldlace=fdf5e6 olive=808000 olivedrab=6b8e23 " +
|
||||
"orange=ffa500 orangered=ff4500 orchid=da70d6 palegoldenrod=eee8aa " +
|
||||
"palegreen=98fb98 paleturquoise=afeeee palevioletred=db7093 papayawhip=ffefd5 " +
|
||||
"peachpuff=ffdab9 peru=cd853f pink=ffc0cb plum=dda0dd powderblue=b0e0e6 " +
|
||||
"purple=800080 rebeccapurple=663399 red=ff0000 rosybrown=bc8f8f royalblue=4169e1 " +
|
||||
"saddlebrown=8b4513 salmon=fa8072 sandybrown=f4a460 seagreen=2e8b57 " +
|
||||
"seashell=fff5ee sienna=a0522d silver=c0c0c0 skyblue=87ceeb slateblue=6a5acd " +
|
||||
"slategray=708090 slategrey=708090 snow=fffafa springgreen=00ff7f " +
|
||||
"steelblue=4682b4 tan=d2b48c teal=008080 thistle=d8bfd8 tomato=ff6347 " +
|
||||
"turquoise=40e0d0 violet=ee82ee wheat=f5deb3 white=ffffff whitesmoke=f5f5f5 " +
|
||||
"yellow=ffff00 yellowgreen=9acd32"
|
||||
@@ -22,6 +22,12 @@ data class IcsEvent(
|
||||
val zoneId: String,
|
||||
/** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */
|
||||
val recurrenceRule: String? = null,
|
||||
/**
|
||||
* Occurrences deleted from the series, in the provider's `EXDATE` shape —
|
||||
* `yyyyMMdd` for an all-day series, a UTC `yyyyMMddTHHmmssZ` stamp
|
||||
* otherwise. Empty for a one-off event.
|
||||
*/
|
||||
val exDates: List<String> = emptyList(),
|
||||
val location: String? = null,
|
||||
val description: String? = null,
|
||||
/** Reminder lead times in minutes before start (raw provider offsets). */
|
||||
|
||||
@@ -7,14 +7,25 @@ import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.math.ceil
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Milliseconds in a calendar day; all-day times are UTC midnights, so this is exact. */
|
||||
private const val DAY_MILLIS = 86_400_000L
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
|
||||
/**
|
||||
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
|
||||
* but [uid] is nullable (an incoming event may carry none; the insert layer
|
||||
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
|
||||
* how the writer encoded them.
|
||||
*
|
||||
* [reminderMinutes] holds raw lead times as the file's `VALARM`s expressed
|
||||
* them — minutes *before* start, so a negative entry means "after start".
|
||||
* [semanticReminderMinutes] turns those into the lead times Calendula models.
|
||||
*/
|
||||
data class ParsedIcsEvent(
|
||||
val uid: String?,
|
||||
@@ -30,8 +41,45 @@ data class ParsedIcsEvent(
|
||||
val status: EventStatus = EventStatus.Confirmed,
|
||||
val availability: Availability = Availability.Busy,
|
||||
val calendarName: String? = null,
|
||||
/** Opaque ARGB the source file gave this event, if any (see [parseIcsColorValue]). */
|
||||
val color: Int? = null,
|
||||
/** Excluded occurrences, already in the provider's `EXDATE` shape. */
|
||||
val exDates: List<String> = emptyList(),
|
||||
/** True when this came from a `VTODO` rather than a `VEVENT`. */
|
||||
val isTask: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* [reminderMinutes] mapped onto Calendula's model: a lead time in minutes before
|
||||
* the event, never negative.
|
||||
*
|
||||
* For a timed event the raw lead time already is that, and a trigger that fires
|
||||
* *after* the start models no lead time at all — it is dropped rather than
|
||||
* clamped, which would invent a reminder at the start the file never asked for
|
||||
* (a legal `TRIGGER:PT30M` follow-up alarm is the case). All-day reminders are
|
||||
* whole days before the event, fired at the user's configured time of day (see
|
||||
* `AllDayReminderEncoding`), so a raw offset counts the whole days its trigger
|
||||
* lands *earlier than* the event's UTC midnight — i.e. it rounds **up**.
|
||||
*
|
||||
* Rounding to the nearest day instead would lose a day for every producer whose
|
||||
* all-day notifications fire after noon: a file's raw offset is
|
||||
* `days × 1440 − timeOfDay`, so "1 day before at 18:00" arrives as
|
||||
* `TRIGGER:-PT6H` (raw 360) and would read as "on the day". Rounding up also
|
||||
* lands Fossify's encoding correctly — it writes "on the day at 09:00" as a
|
||||
* *positive* `TRIGGER:P0DT9H0M0S`, nine hours after the UTC midnight, which
|
||||
* ceils to zero days before.
|
||||
*/
|
||||
fun ParsedIcsEvent.semanticReminderMinutes(): List<Int> = reminderMinutes
|
||||
.mapNotNull { raw ->
|
||||
if (isAllDay) {
|
||||
ceil(raw.toDouble() / MINUTES_PER_DAY).toInt().coerceAtLeast(0) * MINUTES_PER_DAY
|
||||
} else {
|
||||
raw.takeIf { it >= 0 }
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
.sorted()
|
||||
|
||||
/** Things the parser dropped rather than failing — surfaced in the import report. */
|
||||
enum class IcsParseWarning {
|
||||
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
|
||||
@@ -45,6 +93,12 @@ enum class IcsParseWarning {
|
||||
|
||||
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
|
||||
UnknownTimezone,
|
||||
|
||||
/** `VTODO` components were imported as events — Calendula has no task model. */
|
||||
TasksImportedAsEvents,
|
||||
|
||||
/** A malformed `RRULE` was repaired or dropped (see [sanitizeRrule]). */
|
||||
RecurrenceRuleRepaired,
|
||||
}
|
||||
|
||||
data class IcsParseResult(
|
||||
@@ -52,8 +106,28 @@ data class IcsParseResult(
|
||||
val warnings: Set<IcsParseWarning>,
|
||||
)
|
||||
|
||||
/** Outcome of a bulk `.ics` import into one calendar. */
|
||||
data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
|
||||
/**
|
||||
* Outcome of a bulk `.ics` import into one calendar. [failed] counts events the
|
||||
* provider rejected — the import continues past them, so a single unusable
|
||||
* event can't cost the user the rest of the file.
|
||||
*
|
||||
* [notAttempted] counts what a retry still has to import, because the permission
|
||||
* or the target calendar went away mid-file and every remaining insert would have
|
||||
* failed the same way. Events past that point whose UID is already in the
|
||||
* calendar count under [skippedDuplicate] instead — they were never going to be
|
||||
* added either way. It travels in the summary rather than as an exception so the
|
||||
* counts of what *did* land survive: an import is only idempotent for events that
|
||||
* carry a UID, so a retry told "nothing happened" would duplicate the rest.
|
||||
*/
|
||||
data class IcsImportSummary(
|
||||
val imported: Int,
|
||||
val skippedDuplicate: Int,
|
||||
val failed: Int = 0,
|
||||
val notAttempted: Int = 0,
|
||||
)
|
||||
|
||||
/** The non-standard property most exporters name the whole calendar with. */
|
||||
private const val CALENDAR_NAME_PROPERTY = "X-WR-CALNAME"
|
||||
|
||||
/**
|
||||
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
|
||||
@@ -62,6 +136,9 @@ data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
|
||||
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
|
||||
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
|
||||
* resolved against the OS tz database instead ([deviceZone] is the fallback).
|
||||
*
|
||||
* `VTODO` components are read as all-day/timed events: Calendula models no
|
||||
* tasks, and dropping them would silently lose half of some exports.
|
||||
*/
|
||||
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
|
||||
|
||||
@@ -69,42 +146,62 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
val lines = unfoldLines(text)
|
||||
val events = mutableListOf<ParsedIcsEvent>()
|
||||
val warnings = mutableSetOf<IcsParseWarning>()
|
||||
var calendarName: String? = null
|
||||
// Scanned up front, not in document order: an X-WR-CALNAME after the
|
||||
// first VEVENT still names the calendar. The cheap prefix test first,
|
||||
// so a file without one doesn't pay a full parse to find nothing.
|
||||
val calendarName = lines.asSequence()
|
||||
.filter { it.startsWith(CALENDAR_NAME_PROPERTY, ignoreCase = true) }
|
||||
.mapNotNull(::parseContentLine)
|
||||
.firstOrNull { it.name == CALENDAR_NAME_PROPERTY }
|
||||
?.let { unescapeText(it.value).trim().ifEmpty { null } }
|
||||
|
||||
var i = 0
|
||||
while (i < lines.size) {
|
||||
val line = parseContentLine(lines[i])
|
||||
if (line == null) { i++; continue }
|
||||
val component = when {
|
||||
line.isBegin("VEVENT") -> "VEVENT"
|
||||
line.isBegin("VTODO") -> "VTODO"
|
||||
else -> null
|
||||
}
|
||||
when {
|
||||
line.isBegin("VEVENT") -> {
|
||||
val end = indexOfEnd(lines, i + 1, "VEVENT")
|
||||
parseVevent(lines.subList(i + 1, end), calendarName, warnings)
|
||||
?.let(events::add)
|
||||
i = end + 1
|
||||
component != null -> {
|
||||
// A component whose END line never arrives ends at the next
|
||||
// one's BEGIN, not at that one's END: reading to the far END
|
||||
// would fold two events into one body, where the later
|
||||
// properties overwrite the earlier and one event is lost.
|
||||
val end = indexOfEnd(lines, i + 1, component)
|
||||
val body = indexOfNextComponent(lines, i + 1, end)
|
||||
parseComponent(
|
||||
body = lines.subList(i + 1, body),
|
||||
fileCalendarName = calendarName,
|
||||
warnings = warnings,
|
||||
isTask = component == "VTODO",
|
||||
)?.let(events::add)
|
||||
i = if (body < end) body else end + 1
|
||||
}
|
||||
line.isBegin("VTIMEZONE") -> {
|
||||
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
||||
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
|
||||
}
|
||||
line.name == "X-WR-CALNAME" -> {
|
||||
calendarName = unescapeText(line.value).trim().ifEmpty { null }
|
||||
i++
|
||||
}
|
||||
else -> i++
|
||||
}
|
||||
}
|
||||
if (events.any { it.isTask }) warnings.add(IcsParseWarning.TasksImportedAsEvents)
|
||||
return IcsParseResult(events, warnings)
|
||||
}
|
||||
|
||||
private fun parseVevent(
|
||||
private fun parseComponent(
|
||||
body: List<String>,
|
||||
fileCalendarName: String?,
|
||||
warnings: MutableSet<IcsParseWarning>,
|
||||
isTask: Boolean,
|
||||
): ParsedIcsEvent? {
|
||||
var uid: String? = null
|
||||
var summary = ""
|
||||
var dtStart: IcsDateTime? = null
|
||||
var dtEnd: IcsDateTime? = null
|
||||
var due: IcsDateTime? = null
|
||||
var duration: String? = null
|
||||
var rrule: String? = null
|
||||
var location: String? = null
|
||||
@@ -112,7 +209,11 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
var status = EventStatus.Confirmed
|
||||
var availability = Availability.Busy
|
||||
var calendarName = fileCalendarName
|
||||
var category: String? = null
|
||||
var eventColor: Int? = null
|
||||
var calendarColor: Int? = null
|
||||
val reminders = mutableListOf<Int>()
|
||||
val exDateLines = mutableListOf<IcsContentLine>()
|
||||
var skipAsOverride = false
|
||||
|
||||
var i = 0
|
||||
@@ -129,9 +230,11 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
"UID" -> uid = line.value.trim().ifEmpty { null }
|
||||
"SUMMARY" -> summary = unescapeText(line.value)
|
||||
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
|
||||
"DUE" -> if (isTask) due = parseIcsDateTime(line, warnings)
|
||||
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
|
||||
"DURATION" -> duration = line.value.trim()
|
||||
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
|
||||
"EXDATE" -> exDateLines.add(line)
|
||||
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
|
||||
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
|
||||
"STATUS" -> status = mapIcsStatus(line.value)
|
||||
@@ -142,6 +245,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
|
||||
"X-CALENDULA-CALENDAR" ->
|
||||
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
|
||||
// Fossify names the source calendar per event; it never writes
|
||||
// an X-WR-CALNAME, so this is the only calendar label its files
|
||||
// carry. Elsewhere CATEGORIES is a plain tag list, so it only
|
||||
// stands in where nothing named the calendar outright.
|
||||
"CATEGORIES" -> category = singleCategory(line.value) ?: category
|
||||
// Per-event colour, most specific encoding first (see parseIcsColorValue).
|
||||
"X-FOSSIFY-EVENT-COLOR", "X-SMT-EVENT-COLOR" ->
|
||||
eventColor = parseIcsColorValue(line.value) ?: eventColor
|
||||
"COLOR" -> eventColor = eventColor ?: parseIcsColorValue(line.value)
|
||||
// The *calendar's* colour. Kept as a fallback so a migration that
|
||||
// folds several source calendars into one keeps them apart visually.
|
||||
"X-FOSSIFY-CATEGORY-COLOR", "X-SMT-CATEGORY-COLOR", "CATEGORY_COLOR" ->
|
||||
calendarColor = parseIcsColorValue(line.value) ?: calendarColor
|
||||
}
|
||||
i++
|
||||
}
|
||||
@@ -150,37 +266,125 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped)
|
||||
return null
|
||||
}
|
||||
// Most producers give a VTODO only a DUE, which is then the moment the
|
||||
// task sits at. One that carries a DTSTART too means the span between
|
||||
// them, so DUE stands in for the end it has no DTEND for. Resolved after
|
||||
// the sweep rather than inside it: property order isn't guaranteed, and
|
||||
// a DUE ahead of the DTSTART would otherwise be taken for the start.
|
||||
if (isTask && due != null) {
|
||||
if (dtStart == null) dtStart = due else if (dtEnd == null) dtEnd = due
|
||||
}
|
||||
val start = dtStart ?: run {
|
||||
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
|
||||
return null
|
||||
}
|
||||
val end = dtEnd
|
||||
?: duration?.let {
|
||||
start.copy(
|
||||
instant = Instant.fromEpochMilliseconds(
|
||||
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
||||
),
|
||||
)
|
||||
}
|
||||
?: start
|
||||
val recurrence = sanitizeRrule(rrule)
|
||||
if (recurrence.repaired) warnings.add(IcsParseWarning.RecurrenceRuleRepaired)
|
||||
val end = resolveEnd(start, dtEnd, duration)
|
||||
return ParsedIcsEvent(
|
||||
uid = uid,
|
||||
summary = summary,
|
||||
start = start.instant,
|
||||
end = end.instant,
|
||||
end = end,
|
||||
isAllDay = start.isAllDay,
|
||||
zoneId = start.zoneId,
|
||||
recurrenceRule = rrule,
|
||||
recurrenceRule = recurrence.rule,
|
||||
location = location,
|
||||
description = description,
|
||||
reminderMinutes = reminders.distinct(),
|
||||
status = status,
|
||||
availability = availability,
|
||||
calendarName = calendarName,
|
||||
calendarName = calendarName ?: category,
|
||||
color = eventColor ?: calendarColor,
|
||||
exDates = if (recurrence.rule == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
normalizeExDates(exDateLines, start)
|
||||
},
|
||||
isTask = isTask,
|
||||
)
|
||||
}
|
||||
|
||||
/** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
|
||||
/**
|
||||
* The event's end instant.
|
||||
*
|
||||
* All-day events get two RFC 5545 §3.6.1 rules applied that a literal read
|
||||
* would miss: a `DATE`-valued `DTSTART` with no `DTEND` lasts one day, and —
|
||||
* since the provider expands a zero-length all-day series into no instances
|
||||
* at all — an all-day event may never end at or before it starts.
|
||||
*/
|
||||
private fun resolveEnd(
|
||||
start: IcsDateTime,
|
||||
dtEnd: IcsDateTime?,
|
||||
duration: String?,
|
||||
): Instant {
|
||||
val explicit = dtEnd?.instant ?: duration?.let {
|
||||
Instant.fromEpochMilliseconds(
|
||||
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
||||
)
|
||||
}
|
||||
if (!start.isAllDay) return explicit ?: start.instant
|
||||
return explicit?.takeIf { it > start.instant } ?: start.instant.plusDays(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* `EXDATE` values in the shape `Events.EXDATE` wants: `yyyyMMdd` for an
|
||||
* all-day series, a UTC `yyyyMMddTHHmmssZ` stamp otherwise.
|
||||
*
|
||||
* Fossify stores its excluded occurrences as bare day codes and writes them
|
||||
* out that way even for timed series, where the property is then ambiguous.
|
||||
* Such a value is resolved against the series' own time of day, which is
|
||||
* what it meant — its repetitions never move within the day.
|
||||
*
|
||||
* An all-day exclusion names a calendar day, so a zone-qualified DATE-TIME
|
||||
* still means the day it spells out; resolving it to an instant first and
|
||||
* reading *that* back in UTC would roll a midnight east of UTC onto the day
|
||||
* before and exclude an occurrence that doesn't exist.
|
||||
*/
|
||||
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
|
||||
if (lines.isEmpty()) return emptyList()
|
||||
val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone
|
||||
val startTime = start.instant.toLocalDateTime(startZone).time
|
||||
return lines
|
||||
.flatMap { line -> line.value.split(',').map { line to it.trim() } }
|
||||
.mapNotNull { (line, token) ->
|
||||
when {
|
||||
token.isEmpty() -> null
|
||||
start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode)
|
||||
token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp)
|
||||
else -> parseBasicDate(token)
|
||||
?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) }
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
}
|
||||
|
||||
/**
|
||||
* [startZone] is the series' own zone, which a floating value (no `Z`, no
|
||||
* `TZID`) has to be read in — RFC 5545 ties `EXDATE` to `DTSTART`'s form,
|
||||
* and reading it in the device's zone instead would put the exclusion on an
|
||||
* instant no occurrence has.
|
||||
*/
|
||||
private fun parseExDateTime(
|
||||
token: String,
|
||||
line: IcsContentLine,
|
||||
startZone: TimeZone,
|
||||
): Instant? {
|
||||
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
|
||||
val zone = when {
|
||||
token.endsWith("Z") -> TimeZone.UTC
|
||||
else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() }
|
||||
?: startZone
|
||||
}
|
||||
return ldt.toInstant(zone)
|
||||
}
|
||||
|
||||
/**
|
||||
* A `VALARM`'s offset from the event start, in minutes *before* it — so a
|
||||
* trigger that fires after the start (which is how the Fossify family
|
||||
* encodes "on the day at 09:00") comes back negative. Null when the trigger
|
||||
* isn't a usable relative offset.
|
||||
*/
|
||||
private fun parseAlarmMinutes(body: List<String>): Int? {
|
||||
val trigger = body.asSequence()
|
||||
.mapNotNull { parseContentLine(it) }
|
||||
@@ -189,8 +393,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
|
||||
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
|
||||
val millis = parseRfc2445DurationMillis(trigger.value)
|
||||
// Negative = before start (the normal case) → positive lead minutes.
|
||||
return (-millis / 60_000L).toInt().coerceAtLeast(0)
|
||||
return (-millis / 60_000L).toInt()
|
||||
}
|
||||
|
||||
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
|
||||
@@ -218,7 +421,52 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
fun IcsContentLine.isBegin(component: String) =
|
||||
name == "BEGIN" && value.trim().equals(component, true)
|
||||
|
||||
fun Instant.plusDays(days: Int): Instant =
|
||||
Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS)
|
||||
|
||||
fun dayCode(date: LocalDate): String =
|
||||
"%04d%02d%02d".format(date.year, date.month.number, date.day)
|
||||
|
||||
fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
|
||||
"%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second)
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-valued `CATEGORIES`, which is what the Fossify family writes
|
||||
* for the source calendar's name. A multi-item list is a tag list and
|
||||
* names no calendar, so it is ignored.
|
||||
*/
|
||||
fun singleCategory(raw: String): String? {
|
||||
var escaped = false
|
||||
for (c in raw) {
|
||||
when {
|
||||
escaped -> escaped = false
|
||||
c == '\\' -> escaped = true
|
||||
c == ',' -> return null
|
||||
}
|
||||
}
|
||||
return unescapeText(raw).trim()
|
||||
.takeIf { it.isNotEmpty() && !it.equals("null", true) }
|
||||
}
|
||||
|
||||
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||
/**
|
||||
* Where an unterminated component's body has to stop: the next top-level
|
||||
* `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A
|
||||
* nested `VALARM` is part of the body and is not a boundary.
|
||||
*/
|
||||
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int {
|
||||
var i = from
|
||||
while (i < end) {
|
||||
val line = parseContentLine(lines[i])
|
||||
if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) {
|
||||
return i
|
||||
}
|
||||
i++
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||
var i = from
|
||||
while (i < lines.size) {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
private val VALID_FREQ = setOf(
|
||||
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
|
||||
)
|
||||
|
||||
private val VALID_WEEKDAYS = setOf("SU", "MO", "TU", "WE", "TH", "FR", "SA")
|
||||
|
||||
/**
|
||||
* What `EventRecurrence` accepts in each numeric list part: a value range, plus
|
||||
* whether `0` is legal. Its `parseNumberList` throws on a non-number, on an item
|
||||
* outside the range *and* on a zero in the by-position parts, so checking only
|
||||
* that an item is a number would still hand the provider a rule it rejects.
|
||||
*
|
||||
* `BYSETPOS` is the one part it leaves unbounded; `0` is dropped from it anyway
|
||||
* because RFC 5545 §3.3.10 gives it no meaning.
|
||||
*/
|
||||
private val NUMERIC_LIST_PARTS = mapOf(
|
||||
"BYSECOND" to NumericPart(0..59, zeroAllowed = true),
|
||||
"BYMINUTE" to NumericPart(0..59, zeroAllowed = true),
|
||||
"BYHOUR" to NumericPart(0..23, zeroAllowed = true),
|
||||
"BYMONTHDAY" to NumericPart(-31..31, zeroAllowed = false),
|
||||
"BYYEARDAY" to NumericPart(-366..366, zeroAllowed = false),
|
||||
"BYWEEKNO" to NumericPart(-53..53, zeroAllowed = false),
|
||||
"BYMONTH" to NumericPart(1..12, zeroAllowed = false),
|
||||
"BYSETPOS" to NumericPart(Int.MIN_VALUE..Int.MAX_VALUE, zeroAllowed = false),
|
||||
)
|
||||
|
||||
private class NumericPart(private val range: IntRange, private val zeroAllowed: Boolean) {
|
||||
fun accepts(item: String): Boolean {
|
||||
val n = item.toIntOrNull() ?: return false
|
||||
return n in range && (zeroAllowed || n != 0)
|
||||
}
|
||||
}
|
||||
|
||||
/** `UNTIL` in RFC 5545 basic format; anything else fails at expansion time. */
|
||||
private val UNTIL_SHAPE = Regex("""\d{8}(T\d{6}Z?)?""")
|
||||
|
||||
/**
|
||||
* A foreign RRULE made safe to hand `CalendarContract`.
|
||||
*
|
||||
* [rule] is what survived, or null when there is nothing left to salvage.
|
||||
* [repaired] is true only when a part was actually dropped — pure normalisation
|
||||
* (case, a stray separator) doesn't count, so a conformant file is never
|
||||
* reported to the user as faulty.
|
||||
*/
|
||||
data class SanitizedRrule(val rule: String?, val repaired: Boolean)
|
||||
|
||||
/**
|
||||
* Salvage a foreign RRULE.
|
||||
*
|
||||
* The provider validates every RRULE through `EventRecurrence.parse`, which
|
||||
* **throws** on a malformed rule — and the throw surfaces from `insert`, not
|
||||
* from a later read. One bad rule in an imported file would therefore abort the
|
||||
* write it rides on. Producers do emit such rules: Fossify's exporter writes a
|
||||
* bare `;BYDAY=` whenever a weekly event carries no weekday mask
|
||||
* (`Parser.getByDay` interpolates an empty day string unconditionally), which is
|
||||
* the state every weekly event it imported from elsewhere is left in.
|
||||
*
|
||||
* That parser has a fixed table of parts and throws on any name outside it, so
|
||||
* this is a whitelist: an unrecognised part (RFC 7529 `RSCALE`, a vendor `X-`
|
||||
* extension) and an unusable value are dropped rather than passed on to fail the
|
||||
* whole event. Only a missing or unrecognised `FREQ` — without which there is no
|
||||
* rule at all — gives up entirely.
|
||||
*/
|
||||
fun sanitizeRrule(raw: String?): SanitizedRrule {
|
||||
val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty()
|
||||
if (rule.isEmpty()) return SanitizedRrule(rule = null, repaired = false)
|
||||
|
||||
var freqSeen = false
|
||||
var repaired = false
|
||||
val parts = mutableListOf<String>()
|
||||
|
||||
/** The items [valid] accepts, uppercased and comma-joined; null if none survive. */
|
||||
fun keepItems(value: String, valid: (String) -> Boolean): String? {
|
||||
val items = value.split(',').map { it.trim().uppercase() }
|
||||
val kept = items.filter(valid)
|
||||
if (kept.size != items.size) repaired = true
|
||||
return kept.takeIf { it.isNotEmpty() }?.joinToString(",")
|
||||
}
|
||||
|
||||
for (part in rule.split(';')) {
|
||||
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
|
||||
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
|
||||
// being dropped, so it falls through and marks the rule repaired.
|
||||
if (part.isBlank()) continue
|
||||
val key = part.substringBefore('=', "").trim().uppercase()
|
||||
val value = if ('=' in part) part.substringAfter('=').trim() else ""
|
||||
val kept = when {
|
||||
key.isEmpty() || value.isEmpty() -> null
|
||||
key == "FREQ" ->
|
||||
value.uppercase().takeIf { it in VALID_FREQ }
|
||||
?.also { freqSeen = true }
|
||||
?.let { "FREQ=$it" }
|
||||
// A non-positive or non-numeric INTERVAL/COUNT is rejected by the
|
||||
// provider, and a COUNT of zero would expand to no occurrences.
|
||||
key == "INTERVAL" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "INTERVAL=$it" }
|
||||
key == "COUNT" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "COUNT=$it" }
|
||||
key == "UNTIL" ->
|
||||
value.uppercase().takeIf { UNTIL_SHAPE.matches(it) }?.let { "UNTIL=$it" }
|
||||
key == "WKST" ->
|
||||
value.uppercase().takeIf { it in VALID_WEEKDAYS }?.let { "WKST=$it" }
|
||||
key == "BYDAY" -> keepItems(value, ::isWeekdayItem)?.let { "BYDAY=$it" }
|
||||
key in NUMERIC_LIST_PARTS ->
|
||||
keepItems(value, NUMERIC_LIST_PARTS.getValue(key)::accepts)?.let { "$key=$it" }
|
||||
else -> null
|
||||
}
|
||||
if (kept == null) repaired = true else parts += kept
|
||||
}
|
||||
// `EventRecurrence.parse` ends with *two* global checks, not one: a missing
|
||||
// FREQ throws, and so does an UNTIL that arrives together with a COUNT. A
|
||||
// rule carrying both is malformed per RFC 5545 §3.3.10 but real producers
|
||||
// emit it, and left alone it would throw straight out of `insert` — the very
|
||||
// failure this function exists to prevent. UNTIL is kept: it bounds the
|
||||
// series at an absolute date, so a stale COUNT can't over-generate past it.
|
||||
if (parts.any { it.startsWith("UNTIL=") }) {
|
||||
if (parts.removeAll { it.startsWith("COUNT=") }) repaired = true
|
||||
}
|
||||
return if (freqSeen) {
|
||||
SanitizedRrule(rule = parts.joinToString(";"), repaired = repaired)
|
||||
} else {
|
||||
SanitizedRrule(rule = null, repaired = true)
|
||||
}
|
||||
}
|
||||
|
||||
/** A `BYDAY` item: a two-letter weekday, optionally preceded by an ordinal. */
|
||||
private fun isWeekdayItem(item: String): Boolean {
|
||||
if (item.length < 2) return false
|
||||
val ordinal = item.dropLast(2)
|
||||
return item.takeLast(2) in VALID_WEEKDAYS &&
|
||||
(ordinal.isEmpty() || ordinal.toIntOrNull() != null)
|
||||
}
|
||||
@@ -46,6 +46,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||
appendTimes(event)
|
||||
event.recurrenceRule?.takeIf { it.isNotBlank() }
|
||||
?.let { add("RRULE:${it.removePrefix("RRULE:")}") }
|
||||
appendExDates(event)
|
||||
event.location?.takeIf { it.isNotBlank() }
|
||||
?.let { add("LOCATION:${escapeText(it)}") }
|
||||
event.description?.takeIf { it.isNotBlank() }
|
||||
@@ -83,6 +84,20 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The series' deleted occurrences. RFC 5545 ties `EXDATE`'s value type to
|
||||
* `DTSTART`'s, so an all-day series needs the explicit `VALUE=DATE` — without
|
||||
* it the bare day codes read as an invalid DATE-TIME. Only written for a
|
||||
* recurring event: an exclusion names an occurrence, and a one-off has none.
|
||||
*/
|
||||
private fun MutableList<String>.appendExDates(event: IcsEvent) {
|
||||
if (event.recurrenceRule.isNullOrBlank()) return
|
||||
val stamps = event.exDates.filter { it.isNotBlank() }.distinct()
|
||||
if (stamps.isEmpty()) return
|
||||
val prefix = if (event.isAllDay) "EXDATE;VALUE=DATE:" else "EXDATE:"
|
||||
add(prefix + stamps.joinToString(","))
|
||||
}
|
||||
|
||||
private fun MutableList<String>.appendAlarm(minutes: Int, summary: String) {
|
||||
add("BEGIN:VALARM")
|
||||
add("ACTION:DISPLAY")
|
||||
|
||||
@@ -13,6 +13,11 @@ import kotlinx.datetime.toLocalDateTime
|
||||
* calendar, exactly like a fresh create — the user confirms the target and
|
||||
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
|
||||
* handling (provider all-day times are UTC midnights with an exclusive end).
|
||||
*
|
||||
* [ParsedIcsEvent.color] is deliberately not carried over: picking the calendar
|
||||
* is the first thing this form asks for, and that clears any colour (a raw ARGB
|
||||
* is invalid on a calendar whose account publishes a palette). Bulk import,
|
||||
* where the target is known up front, does keep it.
|
||||
*/
|
||||
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||
val (start, end) = if (isAllDay) {
|
||||
@@ -31,7 +36,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||
end = end,
|
||||
location = location.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
reminders = reminderMinutes.distinct().sorted(),
|
||||
reminders = semanticReminderMinutes(),
|
||||
availability = availability,
|
||||
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
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.
|
||||
*
|
||||
* Every offset given is planned. The provider's "use the account default"
|
||||
* sentinel names a lead time no reader can resolve, and is translated out on the
|
||||
* way in
|
||||
* ([de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults]) — taken
|
||||
* as an offset it would arm an alarm a minute after the event began.
|
||||
*/
|
||||
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
|
||||
@@ -1,36 +1,80 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.SeekableTransitionState
|
||||
import androidx.compose.animation.core.rememberTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.jeanlucmakiola.calendula.ui.common.screen
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInWindow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.BelowTopBarClip
|
||||
import de.jeanlucmakiola.calendula.ui.common.StayingPut
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFab
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTopBar
|
||||
import de.jeanlucmakiola.calendula.ui.common.ChromeHost
|
||||
import de.jeanlucmakiola.calendula.ui.common.ChromeSlot
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalChromeSlot
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScroll
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewFocus
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewFocusSaver
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
|
||||
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScrollMemory
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewMorphScope
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform
|
||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.drillToWeek
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
||||
@@ -38,16 +82,21 @@ import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimelineScreen
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Holds the top-level view back stack (spec M1) and swaps between the calendar
|
||||
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
||||
* pill in their top bars writes back here via [onSelectView].
|
||||
* screens. Each screen owns its own ViewModel; they all open on the one focused
|
||||
* date held here ([ViewFocus]), and the view-switcher pill in their top bars
|
||||
* writes back here via [onSelectView].
|
||||
*
|
||||
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
|
||||
* A lateral switch (pill / drawer) builds a visit history so back retraces it
|
||||
@@ -64,6 +113,7 @@ import kotlin.time.Clock
|
||||
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
|
||||
* widget's view.)
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun CalendarHost(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -74,6 +124,7 @@ fun CalendarHost(
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: EventForm? = null,
|
||||
requestedInsertSource: ImportSource = ImportSource.Insert,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
requestedEditKey: LongArray? = null,
|
||||
onEditKeyConsumed: () -> Unit = {},
|
||||
@@ -94,14 +145,25 @@ fun CalendarHost(
|
||||
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||
mutableStateOf(listOf(defaultView))
|
||||
}
|
||||
var navigationVersion by remember { mutableIntStateOf(0) }
|
||||
val view = viewStack.last()
|
||||
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
|
||||
// The date every view opens on and carries along (#184); tapping a day
|
||||
// anywhere focuses it and opens the day view there.
|
||||
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) }
|
||||
|
||||
// Tapping a day in the month grid opens the day view anchored to that date.
|
||||
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val switchScope = rememberCoroutineScope()
|
||||
val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move ->
|
||||
navigationVersion++
|
||||
viewStack = move(viewStack)
|
||||
}
|
||||
val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } }
|
||||
val onOpenDay: (LocalDate) -> Unit = { date ->
|
||||
pendingDayIso = date.toString()
|
||||
viewStack = viewStack.drillToDay()
|
||||
focus.navigateTo(date)
|
||||
switchTo { it.drillToDay() }
|
||||
}
|
||||
val onOpenWeek: (LocalDate) -> Unit = { date ->
|
||||
focus.navigateTo(date)
|
||||
switchTo { it.drillToWeek() }
|
||||
}
|
||||
|
||||
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||
@@ -148,6 +210,10 @@ fun CalendarHost(
|
||||
// over Settings and survives view switches.
|
||||
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Backup & restore (#69) — hoisted like the manager, being driven by the
|
||||
// calendar list rather than by preferences. Reached from both.
|
||||
var showBackup by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Event form (v1.2 create) — same held-key pattern as the detail screen:
|
||||
// [heldCreateIso] keeps the prefill date alive through the slide-out.
|
||||
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
|
||||
@@ -185,10 +251,21 @@ fun CalendarHost(
|
||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||
// keeps routing a single event straight into the prefilled create form.
|
||||
var importForceMany by remember { mutableStateOf(false) }
|
||||
// Where a restore came from, so closing the import puts it back: the import
|
||||
// overlays are declared under Backup & restore and the manager, so starting
|
||||
// one has to close them — without this, finishing a restore drops you on the
|
||||
// calendar and the next file means walking in through Settings again.
|
||||
var backupAfterImport by rememberSaveable { mutableStateOf(false) }
|
||||
var calendarsAfterImport by rememberSaveable { mutableStateOf(false) }
|
||||
// One import run. The import VM lives in the Activity's store, so this is
|
||||
// what tells it a *re-import of the same file* is new work and not the run
|
||||
// it already finished; a rotation keeps the number and keeps the result.
|
||||
var importSession by rememberSaveable { mutableStateOf(0) }
|
||||
LaunchedEffect(requestedImportUri) {
|
||||
if (requestedImportUri != null) {
|
||||
importUri = requestedImportUri
|
||||
importForceMany = false
|
||||
importSession++
|
||||
onImportConsumed()
|
||||
}
|
||||
}
|
||||
@@ -198,7 +275,7 @@ fun CalendarHost(
|
||||
// reveals on top of whatever was open without extra dismissal.
|
||||
LaunchedEffect(requestedInsertForm) {
|
||||
if (requestedInsertForm != null) {
|
||||
importFormSource = ImportSource.Insert
|
||||
importFormSource = requestedInsertSource
|
||||
importForm = requestedInsertForm
|
||||
onInsertConsumed()
|
||||
}
|
||||
@@ -210,6 +287,7 @@ fun CalendarHost(
|
||||
fun dismissCoveringOverlays() {
|
||||
showSettings = false
|
||||
showCalendars = false
|
||||
showBackup = false
|
||||
detailKey = null
|
||||
editKey = null
|
||||
importUri = null
|
||||
@@ -235,13 +313,14 @@ fun CalendarHost(
|
||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||
// view so backing out returns there (then home), not to the default.
|
||||
LaunchedEffect(widgetNavRequest) {
|
||||
if (widgetNavRequest != null) navigationVersion++
|
||||
when (val req = widgetNavRequest) {
|
||||
is WidgetNavRequest.OpenDate -> {
|
||||
// Drill the day view in over the widget's view: drop any overlay
|
||||
// that would cover it, so the open doesn't land under Settings/form.
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
pendingDayIso = req.dateIso
|
||||
focus.navigateTo(LocalDate.parse(req.dateIso))
|
||||
// No widget source (an external date tap) roots over the default
|
||||
// home view, so backing out of the day returns home then exits.
|
||||
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
||||
@@ -265,7 +344,7 @@ fun CalendarHost(
|
||||
// default home — so backing out returns to the default, then exits.
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
pendingDayIso = null
|
||||
focus.navigateTo(localToday())
|
||||
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
@@ -287,90 +366,215 @@ fun CalendarHost(
|
||||
}
|
||||
}
|
||||
|
||||
// Drag to reschedule (#68). Hosted here so one instance serves every view and
|
||||
// survives view switches; the calendar surfaces read it out of LocalEventMove.
|
||||
val reschedule: RescheduleViewModel = hiltViewModel()
|
||||
val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle()
|
||||
val onEditEvent: (EventInstance) -> Unit = { event ->
|
||||
val key = longArrayOf(
|
||||
event.eventId,
|
||||
event.start.toEpochMilliseconds(),
|
||||
event.end.toEpochMilliseconds(),
|
||||
)
|
||||
heldEditKey = key
|
||||
editKey = key
|
||||
}
|
||||
// Off by preference (#173) switches off the gesture, not the scope: blocks
|
||||
// register no drag, while the edit form and its TalkBack action stay put.
|
||||
val dragToReschedule = viewModel.dragToReschedule.collectAsStateWithLifecycle().value
|
||||
val moveScope = remember(movableCalendarIds, reschedule, dragToReschedule) {
|
||||
EventMoveScope(
|
||||
movableCalendarIds = movableCalendarIds,
|
||||
dragEnabled = dragToReschedule,
|
||||
move = { reschedule.move(it) },
|
||||
inFlight = reschedule.inFlight,
|
||||
undoStarted = reschedule.undoStarted,
|
||||
abandoned = reschedule.abandoned,
|
||||
edit = onEditEvent,
|
||||
)
|
||||
}
|
||||
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
// Overlays leave from the predictive-back preview (#184): shrinking on from
|
||||
// where the gesture let go instead of snapping back and sliding off.
|
||||
val overlayExit = predictiveBackExit()
|
||||
|
||||
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
||||
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
||||
// so back there falls through to the system and exits the app.
|
||||
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
||||
editKey != null || showSettings || showCalendars || importUri != null ||
|
||||
importForm != null
|
||||
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
||||
viewStack = viewStack.dropLast(1)
|
||||
editKey != null || showSettings || showCalendars || showBackup ||
|
||||
importUri != null || importForm != null
|
||||
// The view switch runs on a seekable transition, so a back gesture can scrub
|
||||
// it (#185): the previous view morphs in under the finger, releasing finishes
|
||||
// the pop and cancelling springs back. Reduced motion keeps a plain back.
|
||||
val viewTransition = remember { SeekableTransitionState(view) }
|
||||
LaunchedEffect(view) { viewTransition.animateTo(view) }
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val viewBackEnabled = !anyOverlayVisible && viewStack.size > 1
|
||||
if (reduceMotion) {
|
||||
BackHandler(enabled = viewBackEnabled) {
|
||||
navigationVersion++
|
||||
viewStack = viewStack.dropLast(1)
|
||||
}
|
||||
} else {
|
||||
PredictiveBackHandler(enabled = viewBackEnabled) { events ->
|
||||
val previous = viewStack[viewStack.size - 2]
|
||||
try {
|
||||
events.collect { viewTransition.seekTo(it.progress, targetState = previous) }
|
||||
navigationVersion++
|
||||
viewStack = viewStack.dropLast(1)
|
||||
} catch (e: CancellationException) {
|
||||
withContext(NonCancellable) { viewTransition.animateTo(viewTransition.currentState) }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
// Switching between the peer views (month/week/day/agenda) is lateral
|
||||
// Switching between the month, timeline and agenda screens is lateral
|
||||
// navigation, so it fades through rather than sliding — paging *within* a
|
||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||
// view keeps the directional slide. What both views show morphs across
|
||||
// (#184): see [ViewMorphKey]. Reduced motion keeps the plain fade.
|
||||
val viewSwitch = fadeThrough()
|
||||
AnimatedContent(
|
||||
targetState = view,
|
||||
transitionSpec = { viewSwitch },
|
||||
label = "view-switch",
|
||||
) { currentView ->
|
||||
when (currentView) {
|
||||
CalendarView.Week -> WeekScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
initialDateIso = pendingDayIso,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
val morphBounds = rememberViewMorphBoundsTransform()
|
||||
val morphFade = rememberCalendarFadeSpec()
|
||||
val morphClip = remember { BelowTopBarClip() }
|
||||
val stayingPut = remember { StayingPut() }
|
||||
val chromeHost = remember { ChromeHost() }
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val density = LocalDensity.current
|
||||
val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) +
|
||||
with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
||||
val timelineScroll = remember { TimelineScrollMemory() }
|
||||
// A timeline reached from another screen opens on the visible hours
|
||||
// (#322); only a switch between timeline views carries the scroll over.
|
||||
val timelineZoom = LocalTimelineZoom.current
|
||||
LaunchedEffect(view.screen) {
|
||||
if (view.screen != ViewScreen.Timeline && timelineZoom.hours.enabled) {
|
||||
timelineScroll.midnightY = null
|
||||
}
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalEventMove provides moveScope,
|
||||
LocalTimelineScroll provides timelineScroll,
|
||||
LocalViewFocus provides focus,
|
||||
// The failure state's way out when every calendar is switched off
|
||||
// (#239); nothing else in the calendar surfaces navigates here.
|
||||
LocalManageCalendars provides remember { { showCalendars = true } },
|
||||
) {
|
||||
// One drawer, top bar and FAB for every screen (#184): a switch changes
|
||||
// what they say, from what the screen on show publishes, rather than
|
||||
// swapping them for another screen's.
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
// Open only via the menu button where edge-swipe would fight paging;
|
||||
// the agenda has no paging to fight.
|
||||
gesturesEnabled = drawerState.isOpen || view.screen == ViewScreen.Agenda,
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
currentView = view,
|
||||
currentDate = chromeHost.chrome?.drawerDate ?: focus.date,
|
||||
drawerState = drawerState,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { target ->
|
||||
// Once the drawer is out of the way, so the switch is seen whole.
|
||||
val requestVersion = ++navigationVersion
|
||||
switchScope.launch {
|
||||
drawerState.close()
|
||||
if (navigationVersion == requestVersion) onSelectView(target)
|
||||
}
|
||||
},
|
||||
onJumpToDate = { target ->
|
||||
chromeHost.actions?.onJumpToDate?.invoke(target)
|
||||
switchScope.launch { drawerState.close() }
|
||||
},
|
||||
onSettings = {
|
||||
onOpenSettings()
|
||||
switchScope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CalendarTopBar(
|
||||
host = chromeHost,
|
||||
selectedView = view,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
onCycleView = { onSelectView(viewStack.last().next(quickSwitchViews)) },
|
||||
onOpenDrawer = { switchScope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = todayInToolbar,
|
||||
)
|
||||
},
|
||||
floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) },
|
||||
) { innerPadding ->
|
||||
SharedTransitionLayout(
|
||||
Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y },
|
||||
) {
|
||||
// Keyed on the screen, not the view: the day, multi-day and week views
|
||||
// are one timeline that resizes between them itself (#184), so switching
|
||||
// among them must not replay this transition.
|
||||
rememberTransition(viewTransition, label = "view-switch").AnimatedContent(
|
||||
transitionSpec = { viewSwitch },
|
||||
contentKey = { it.screen },
|
||||
) { currentView ->
|
||||
val screen = currentView.screen
|
||||
val sharedScope = this@SharedTransitionLayout
|
||||
val visibilityScope = this@AnimatedContent
|
||||
val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut) {
|
||||
if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalViewMorph provides viewMorph,
|
||||
LocalChromeSlot provides ChromeSlot(chromeHost, current = screen == view.screen),
|
||||
) {
|
||||
when (screen) {
|
||||
ViewScreen.Timeline -> TimelineScreen(
|
||||
view = currentView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
ViewScreen.Month -> MonthScreen(
|
||||
onOpenDay = onOpenDay,
|
||||
onOpenWeek = onOpenWeek,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
ViewScreen.Agenda -> AgendaScreen(
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scope prompt + confirmation/undo snackbar for a dropped event, declared
|
||||
// right after the calendar views so later overlays cover it.
|
||||
EventMoveHost(reschedule, modifier = Modifier.fillMaxSize())
|
||||
|
||||
// Search overlay — below detail/edit in the Box so a tapped result's
|
||||
// detail screen draws on top, and closing it returns to the results.
|
||||
AnimatedVisibility(
|
||||
visible = showSearch,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
SearchScreen(
|
||||
onBack = { showSearch = false },
|
||||
onEventClick = onEventClick,
|
||||
visible = showSearch,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -379,7 +583,7 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = detailKey != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
activeKey?.let { key ->
|
||||
EventDetailScreen(
|
||||
@@ -399,6 +603,7 @@ fun CalendarHost(
|
||||
importForm = form
|
||||
detailKey = null
|
||||
},
|
||||
visible = detailKey != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -407,7 +612,7 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = createDateIso != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
(createDateIso ?: heldCreateIso)?.let { iso ->
|
||||
EventEditScreen(
|
||||
@@ -415,6 +620,8 @@ fun CalendarHost(
|
||||
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||
onClose = { createDateIso = null },
|
||||
onSaved = { createDateIso = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
visible = createDateIso != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -423,7 +630,7 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = editKey != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
(editKey ?: heldEditKey)?.let { key ->
|
||||
EventEditScreen(
|
||||
@@ -434,6 +641,8 @@ fun CalendarHost(
|
||||
editKey = null
|
||||
detailKey = null
|
||||
},
|
||||
onManageCalendars = { showCalendars = true },
|
||||
visible = editKey != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -442,23 +651,13 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = showSettings,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
SettingsScreen(
|
||||
onBack = { showSettings = false },
|
||||
onManageCalendars = { showCalendars = 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 },
|
||||
onOpenBackup = { showBackup = true },
|
||||
visible = showSettings,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -467,10 +666,28 @@ fun CalendarHost(
|
||||
importUri?.let { uri ->
|
||||
ImportScreen(
|
||||
uri = uri,
|
||||
session = importSession,
|
||||
forceMany = importForceMany,
|
||||
onClose = { importUri = null },
|
||||
onClose = {
|
||||
importUri = null
|
||||
// Back to the surface the restore started from, ready for
|
||||
// the next file.
|
||||
showCalendars = calendarsAfterImport
|
||||
showBackup = backupAfterImport
|
||||
calendarsAfterImport = false
|
||||
backupAfterImport = false
|
||||
},
|
||||
onManageCalendars = {
|
||||
// The manager is where this leads, so it must not be
|
||||
// reopened underneath on close.
|
||||
calendarsAfterImport = false
|
||||
backupAfterImport = false
|
||||
showCalendars = true
|
||||
},
|
||||
onOpenSingle = { form ->
|
||||
importUri = null
|
||||
calendarsAfterImport = false
|
||||
backupAfterImport = false
|
||||
importFormSource = ImportSource.File
|
||||
importForm = form
|
||||
},
|
||||
@@ -483,8 +700,59 @@ fun CalendarHost(
|
||||
initialFormSource = importFormSource,
|
||||
onClose = { importForm = null },
|
||||
onSaved = { importForm = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
)
|
||||
}
|
||||
|
||||
// Declared last so it covers every overlay that can open it: Settings,
|
||||
// both event forms, and the .ics import picker (#76).
|
||||
AnimatedVisibility(
|
||||
visible = showCalendars,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
CalendarsScreen(
|
||||
onBack = { showCalendars = false },
|
||||
onOpenBackup = { showBackup = true },
|
||||
visible = showCalendars,
|
||||
)
|
||||
}
|
||||
|
||||
// Backup & restore — over the manager, since the manager links into it.
|
||||
AnimatedVisibility(
|
||||
visible = showBackup,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
// Settings can open this screen without the manager underneath, so
|
||||
// its failure states' "Manage calendars" way out has to open the
|
||||
// manager rather than just pop — popping alone lands on Settings
|
||||
// (#304). Coming from the manager the flag is already set, so this
|
||||
// is the same pop-back there.
|
||||
CompositionLocalProvider(
|
||||
LocalManageCalendars provides {
|
||||
showCalendars = true
|
||||
showBackup = false
|
||||
},
|
||||
) {
|
||||
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
|
||||
importSession++
|
||||
backupAfterImport = true
|
||||
calendarsAfterImport = showCalendars
|
||||
showBackup = false
|
||||
showCalendars = false
|
||||
},
|
||||
visible = showBackup,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,3 +761,6 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
||||
save = { stack -> stack.map(CalendarView::ordinal) },
|
||||
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
|
||||
)
|
||||
|
||||
private fun localToday(): LocalDate =
|
||||
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
|
||||
@@ -53,4 +53,18 @@ class CalendarHostViewModel @Inject constructor(
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether events may be dragged to another slot to reschedule them (#68, #173).
|
||||
* Off until the stored value arrives, even though the preference itself defaults
|
||||
* on: the wrong guess costs an enabled user a gesture for the few frames a cold
|
||||
* start takes to read DataStore, but re-arms the one thing a disabled user
|
||||
* switched off to be rid of.
|
||||
*/
|
||||
val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@ package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -21,11 +25,22 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.onboardingSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.rememberOnboardingSlideSpec
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
|
||||
/** What the root is showing: a wizard step, the app, or neither yet. */
|
||||
private sealed interface RootTarget {
|
||||
/** DataStore has not emitted yet — render nothing rather than the wrong screen. */
|
||||
data object Loading : RootTarget
|
||||
data object App : RootTarget
|
||||
data class Step(val step: OnboardingStep) : RootTarget
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun RootScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -36,6 +51,8 @@ fun RootScreen(
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
|
||||
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
requestedEditKey: LongArray? = null,
|
||||
onEditKeyConsumed: () -> Unit = {},
|
||||
@@ -48,6 +65,10 @@ fun RootScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// A launch scan already covers the app coming up with the permission, so
|
||||
// only a grant made during this session owes a re-scan.
|
||||
val grantedAtLaunch = remember { hasPermission }
|
||||
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle) {
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
@@ -67,44 +88,87 @@ fun RootScreen(
|
||||
onDispose { lifecycle.removeObserver(obs) }
|
||||
}
|
||||
|
||||
// Cross-fade the one-time onboarding gates so granting permission / finishing
|
||||
// onboarding eases into the next screen instead of snapping. A fade carries no
|
||||
// spatial motion, so it stays appropriate under "remove animations" too.
|
||||
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||
Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted ->
|
||||
if (granted) {
|
||||
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
||||
// Null until DataStore's first emission — render nothing for that
|
||||
// frame instead of flashing the wrong screen.
|
||||
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
||||
when (done) {
|
||||
true -> CalendarHost(
|
||||
modifier = modifier,
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||
widgetNavRequest = widgetNavRequest,
|
||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = onImportConsumed,
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
onInsertConsumed = onInsertConsumed,
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = onEditKeyConsumed,
|
||||
)
|
||||
false -> ReminderOnboardingScreen(
|
||||
onFinished = reminderOnboarding::finish,
|
||||
modifier = modifier,
|
||||
)
|
||||
null -> {}
|
||||
}
|
||||
val onboarding: OnboardingViewModel = hiltViewModel()
|
||||
val plan by onboarding.plan.collectAsStateWithLifecycle()
|
||||
// The grant is reported however it was taken, including via Android's
|
||||
// app-settings screen (caught by the ON_RESUME above); arming the wizard
|
||||
// hangs off that, not off the in-app callback.
|
||||
LaunchedEffect(hasPermission) {
|
||||
onboarding.setHasPermission(hasPermission)
|
||||
if (hasPermission && !grantedAtLaunch) onboarding.onPermissionGranted()
|
||||
}
|
||||
|
||||
// Arms the one-time explainer for the switch to the device's own calendar
|
||||
// visibility (#75), which the wizard then carries as a step of its own.
|
||||
LaunchedEffect(hasPermission) {
|
||||
if (hasPermission) onboarding.reconcileCalendarVisibility()
|
||||
}
|
||||
|
||||
val current = plan
|
||||
val target: RootTarget = when {
|
||||
current == null -> RootTarget.Loading
|
||||
current.current == null -> RootTarget.App
|
||||
else -> RootTarget.Step(current.current)
|
||||
}
|
||||
|
||||
// Which way the flow is moving, so a step-back slides back. The app sorts
|
||||
// after every step, so finishing the wizard reads as one more move forward.
|
||||
val ordinal = when (target) {
|
||||
RootTarget.Loading -> Int.MIN_VALUE
|
||||
RootTarget.App -> Int.MAX_VALUE
|
||||
is RootTarget.Step -> current?.steps?.indexOf(target.step) ?: 0
|
||||
}
|
||||
var lastOrdinal by remember { mutableIntStateOf(ordinal) }
|
||||
val slideDir = if (ordinal < lastOrdinal) -1 else 1
|
||||
SideEffect { lastOrdinal = ordinal }
|
||||
|
||||
val slideSpec = rememberOnboardingSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
AnimatedContent(
|
||||
targetState = target,
|
||||
transitionSpec = {
|
||||
// Coming off the blank first frame is not a step change.
|
||||
if (initialState == RootTarget.Loading) {
|
||||
fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||
} else {
|
||||
onboardingSlideTransition(
|
||||
slideDir = slideDir,
|
||||
spec = slideSpec,
|
||||
fadeSpec = fadeSpec,
|
||||
reduceMotion = reduceMotion,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
PermissionScreen(
|
||||
onGranted = { hasPermission = true },
|
||||
},
|
||||
label = "onboardingGate",
|
||||
) { shown ->
|
||||
when (shown) {
|
||||
RootTarget.Loading -> Unit
|
||||
RootTarget.App -> CalendarHost(
|
||||
modifier = modifier,
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||
widgetNavRequest = widgetNavRequest,
|
||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = onImportConsumed,
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
requestedInsertSource = requestedInsertSource,
|
||||
onInsertConsumed = onInsertConsumed,
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = onEditKeyConsumed,
|
||||
)
|
||||
// Both halves of a transition read their position out of the live
|
||||
// plan; the one the outgoing step was drawn with is already gone.
|
||||
is RootTarget.Step -> current?.let { plan ->
|
||||
OnboardingSteps(
|
||||
step = shown.step,
|
||||
plan = plan,
|
||||
viewModel = onboarding,
|
||||
onPermissionGranted = { hasPermission = true },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,20 +92,27 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
|
||||
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026"), with the start's
|
||||
* year shown too only when it differs from the end's.
|
||||
*
|
||||
* [monthAsSpan] puts [AgendaRange.ThisMonth] on the "start – end" form as well.
|
||||
* The agenda's own header browses the whole month, so the month name is right
|
||||
* there; the range picker previews the window an option opens *today*, which for
|
||||
* "This month" is only the rest of it.
|
||||
*/
|
||||
fun agendaRangeWindowSummary(
|
||||
range: AgendaRange,
|
||||
start: LocalDate,
|
||||
end: LocalDate,
|
||||
locale: Locale,
|
||||
monthAsSpan: Boolean = false,
|
||||
): String {
|
||||
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
||||
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
||||
val dayMonth = localizedDateFormatter(locale, "dMMM")
|
||||
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
|
||||
return when (range) {
|
||||
AgendaRange.Day -> dayMonthYear.format(javaStart)
|
||||
AgendaRange.ThisMonth -> localizedDateFormatter(locale, "LLLLy").format(javaStart)
|
||||
return when {
|
||||
range == AgendaRange.Day -> dayMonthYear.format(javaStart)
|
||||
range == AgendaRange.ThisMonth && !monthAsSpan ->
|
||||
localizedDateFormatter(locale, "LLLLy").format(javaStart)
|
||||
else -> {
|
||||
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
|
||||
"${startFmt.format(javaStart)} – ${dayMonthYear.format(javaEnd)}"
|
||||
|
||||
@@ -24,13 +24,17 @@ 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.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
@@ -128,8 +132,8 @@ internal fun AgendaEventRow(
|
||||
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),
|
||||
title = declinedTitle(title, event.isDeclined),
|
||||
summary = AnnotatedString(agendaTimeSummary(event, day, zone)),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
leading = {
|
||||
@@ -137,7 +141,7 @@ internal fun AgendaEventRow(
|
||||
modifier = Modifier
|
||||
.size(width = 6.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(eventFill(event.color, dark, soften)),
|
||||
.background(eventAccent(event.color, dark, soften)),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenChrome
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
|
||||
import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
||||
import de.jeanlucmakiola.calendula.ui.common.morphEvent
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -13,34 +26,25 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Coffee
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -51,16 +55,13 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
@@ -76,26 +77,22 @@ import kotlin.time.Instant
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AgendaScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
todayInToolbar: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AgendaViewModel = hiltViewModel(),
|
||||
) {
|
||||
// Opens on the focused date and hands back the day scrolled to the top.
|
||||
val focus = LocalViewFocus.current
|
||||
EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) }
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
var showRangePicker by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -105,91 +102,102 @@ fun AgendaScreen(
|
||||
}
|
||||
val successState = state as? AgendaUiState.Success
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = anchor,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onJumpToDate = { target ->
|
||||
viewModel.goToDate(target)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onSettings = {
|
||||
onOpenSettings()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
AgendaTopBar(
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = viewModel::goToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
// The agenda window always starts at the anchor, so moving through it is a
|
||||
// list scroll, not an anchor change — "today" has to bring the list back to
|
||||
// the top as well, and stay offered while it is scrolled away (#305).
|
||||
val listState = rememberLazyListState()
|
||||
val scrolledAway by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
|
||||
}
|
||||
}
|
||||
// A new window is rendered from its first day, so the list belongs at the
|
||||
// top. Keyed on the anchor the days arrived with, not the one just
|
||||
// requested: scrolling before the new rows compose leaves the list keyed to
|
||||
// a row that reappears further down, and it follows it there.
|
||||
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
|
||||
val jumpToToday: () -> Unit = {
|
||||
// Off today, moving the anchor is what resets the list (above); on today
|
||||
// the window doesn't change, so the scroll back is the whole action.
|
||||
if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
|
||||
viewModel.goToToday()
|
||||
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
|
||||
}
|
||||
|
||||
PublishChrome(
|
||||
ScreenChrome(
|
||||
title = stringResource(R.string.view_agenda),
|
||||
titleDate = null,
|
||||
drawerDate = anchor,
|
||||
awayFromToday = !isOnToday || (scrolledAway && successState != null),
|
||||
todayText = stringResource(R.string.agenda_today_action),
|
||||
),
|
||||
ScreenActions(
|
||||
onToday = jumpToToday,
|
||||
onJumpToDate = { target ->
|
||||
focus?.date = target
|
||||
viewModel.goToDate(target)
|
||||
},
|
||||
floatingActionButton = {
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnToday && !todayInToolbar,
|
||||
todayText = stringResource(R.string.agenda_today_action),
|
||||
onToday = viewModel::goToToday,
|
||||
onCreate = { onCreateEvent(anchor, null) },
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
// One bar at the top: the "showing …" header on the left and the
|
||||
// session range switcher on the right (one settings toggle).
|
||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
// end aligns the selector's right edge with the top-bar view
|
||||
// switcher (its 8.dp margin + the app bar's 4.dp inset).
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
AgendaRangeBanner(
|
||||
range = s.range,
|
||||
start = s.anchor,
|
||||
end = s.rangeEnd,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
AgendaRangePill(
|
||||
range = s.range,
|
||||
isOverride = s.rangeIsOverride,
|
||||
onClick = { showRangePicker = true },
|
||||
)
|
||||
}
|
||||
onCreate = { onCreateEvent(anchor, null) },
|
||||
),
|
||||
)
|
||||
|
||||
ScreenBody(modifier = modifier) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
// One bar at the top: the "showing …" header on the left and the
|
||||
// session range switcher on the right (one settings toggle).
|
||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||
// end lines the selector up with whatever ends the top bar:
|
||||
// the view switcher's background, or — once #150 hides it —
|
||||
// the search icon's glyph.
|
||||
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
|
||||
AppBarSpacing.Inset
|
||||
} else {
|
||||
AppBarSpacing.IconTrailingInset
|
||||
}
|
||||
AgendaContent(
|
||||
state = state,
|
||||
pastDisplay = pastDisplay,
|
||||
showToday = showToday,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = RANGE_BAR_TEXT_INSET,
|
||||
end = selectorEnd,
|
||||
top = 8.dp,
|
||||
bottom = 8.dp,
|
||||
),
|
||||
) {
|
||||
AgendaRangeBanner(
|
||||
range = s.range,
|
||||
start = s.anchor,
|
||||
end = s.rangeEnd,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
AgendaRangePill(
|
||||
range = s.range,
|
||||
isOverride = s.rangeIsOverride,
|
||||
onClick = { showRangePicker = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
AgendaContent(
|
||||
state = state,
|
||||
pastDisplay = pastDisplay,
|
||||
showToday = showToday,
|
||||
listState = listState,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onTopDay = if (successState?.anchor == anchor) {
|
||||
{ focus?.date = it }
|
||||
} else null,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +206,7 @@ fun AgendaScreen(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
description = stringResource(R.string.agenda_range_override_hint),
|
||||
selected = successState?.range ?: AgendaRange.Month,
|
||||
weekStart = weekStart,
|
||||
onSelect = viewModel::setRangeOverride,
|
||||
onDismiss = { showRangePicker = false },
|
||||
)
|
||||
@@ -242,6 +251,9 @@ private fun AgendaRangePill(
|
||||
}
|
||||
}
|
||||
|
||||
/** Optical start inset for the range bar's text, set against the title above it. */
|
||||
private val RANGE_BAR_TEXT_INSET = 28.dp
|
||||
|
||||
/**
|
||||
* A header naming the concrete window currently shown under a "showing …" label,
|
||||
* e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name
|
||||
@@ -273,7 +285,7 @@ private fun AgendaRangeBanner(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaContent(
|
||||
internal fun AgendaContent(
|
||||
state: AgendaUiState,
|
||||
pastDisplay: PastEventDisplay,
|
||||
showToday: Boolean,
|
||||
@@ -281,6 +293,8 @@ private fun AgendaContent(
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
onTopDay: ((LocalDate) -> Unit)? = null,
|
||||
) {
|
||||
when (state) {
|
||||
AgendaUiState.Loading -> Box(modifier)
|
||||
@@ -309,16 +323,21 @@ private fun AgendaContent(
|
||||
enabled = showToday && state.anchor == state.today,
|
||||
)
|
||||
if (days.isEmpty()) {
|
||||
// Nothing to scroll: drop the position a previous list left, so
|
||||
// the today FAB doesn't linger over an empty screen.
|
||||
LaunchedEffect(Unit) { listState.scrollToItem(0) }
|
||||
AgendaEmpty(modifier)
|
||||
} else {
|
||||
AgendaList(
|
||||
days = days,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
listState = listState,
|
||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||
now = now,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onTopDay = onTopDay,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -332,14 +351,28 @@ private fun AgendaList(
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
listState: LazyListState,
|
||||
dimPast: Boolean,
|
||||
now: Instant,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onTopDay: ((LocalDate) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (onTopDay != null) {
|
||||
val itemDates by rememberUpdatedState(agendaItemDates(days))
|
||||
val currentOnTopDay by rememberUpdatedState(onTopDay)
|
||||
// The day the list opened on is where the view already is; only a
|
||||
// scroll moves it on.
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.firstVisibleItemIndex }
|
||||
.drop(1)
|
||||
.collect { index -> itemDates.getOrNull(index)?.let(currentOnTopDay) }
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
state = listState,
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
@@ -369,7 +402,7 @@ private fun AgendaList(
|
||||
zone = zone,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = animateItemMotion(),
|
||||
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
}
|
||||
@@ -379,6 +412,13 @@ private fun AgendaList(
|
||||
}
|
||||
}
|
||||
|
||||
/** The day of each item [AgendaList] lays out for [days], in its order. */
|
||||
internal fun agendaItemDates(days: List<AgendaDay>): List<LocalDate> =
|
||||
days.flatMap { day ->
|
||||
// Header, the placeholder or the events, then the gap.
|
||||
List(2 + day.events.size.coerceAtLeast(1)) { day.date }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
@@ -400,51 +440,3 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AgendaTopBar(
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.view_agenda),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(R.string.month_open_menu),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TodayAction(show = showTodayButton, onToday = onToday)
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = stringResource(R.string.search_action),
|
||||
)
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
onCycle = onCycleView,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,42 +1,16 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The zone the event's calendar dates live in. Timed events are resolved in the
|
||||
* device [zone]; all-day events live at UTC midnights with an exclusive end, so
|
||||
* resolving them anywhere but UTC shifts the boundaries — east of UTC that leaks
|
||||
* a one-day event onto its next day. Matches the Week view and detail card.
|
||||
*/
|
||||
private fun EventInstance.dateZone(zone: TimeZone): TimeZone =
|
||||
if (isAllDay) TimeZone.UTC else zone
|
||||
|
||||
/** The first calendar day this event occupies. */
|
||||
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
|
||||
start.toLocalDateTime(dateZone(zone)).date
|
||||
|
||||
/**
|
||||
* The last calendar day this event actually occupies. An event ending exactly at
|
||||
* midnight (all-day events end at the exclusive next-midnight) does not reach
|
||||
* into that boundary day, so resolve the instant just before [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)
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -113,11 +87,7 @@ fun groupAgendaDays(
|
||||
return byDay.map { (date, dayEvents) ->
|
||||
AgendaDay(
|
||||
date = date,
|
||||
events = dayEvents.sortedWith(
|
||||
compareByDescending<EventInstance> { it.isAllDay }
|
||||
.thenBy { it.start }
|
||||
.thenBy { it.title },
|
||||
),
|
||||
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -48,8 +49,12 @@ class AgendaViewModel @Inject constructor(
|
||||
settingsPrefs.agendaShowRangeBar,
|
||||
) { range, showBar -> AgendaSettings(range, showBar) }
|
||||
|
||||
// First day of the week, for the calendar-aligned "this week" range.
|
||||
private val weekStartDay = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||
/**
|
||||
* First day of the week, for the calendar-aligned "this week" range. Public
|
||||
* because the range picker resolves each option to real dates, which needs
|
||||
* the same week start the window is built from.
|
||||
*/
|
||||
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||
|
||||
/**
|
||||
* How to treat events that already ended today (show / dim / hide). A display
|
||||
@@ -90,7 +95,7 @@ class AgendaViewModel @Inject constructor(
|
||||
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
||||
|
||||
val state: StateFlow<AgendaUiState> =
|
||||
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart ->
|
||||
combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart ->
|
||||
AgendaParams(
|
||||
anchor = anchor,
|
||||
range = override ?: settings.range,
|
||||
@@ -152,9 +157,7 @@ class AgendaViewModel @Inject constructor(
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): AgendaUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) }
|
||||
val anchor = params.anchor
|
||||
val rangeEnd = anchor.plus(
|
||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleAgendaEvents
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/** How far ahead the agenda preview's stand-in window runs. */
|
||||
private const val PREVIEW_WINDOW_DAYS = 14
|
||||
|
||||
/**
|
||||
* A live, scaled-down Agenda view for the onboarding view chooser. Today and
|
||||
* the day headers are real; only the events are stand-ins.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AgendaViewPreview(
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
val state = remember(today, zone) { sampleAgendaState(today, zone) }
|
||||
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
AgendaContent(
|
||||
state = state,
|
||||
// Stand-in events are all upcoming, so the past-event preference
|
||||
// cannot change what the preview shows — keep it at the default.
|
||||
pastDisplay = PastEventDisplay.SHOW,
|
||||
showToday = true,
|
||||
onRetry = {},
|
||||
onEventClick = {},
|
||||
onOpenDay = {},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample agenda state, grouped through the same helper the live view uses. */
|
||||
private fun sampleAgendaState(today: LocalDate, zone: TimeZone): AgendaUiState.Success {
|
||||
val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY)
|
||||
return AgendaUiState.Success(
|
||||
anchor = today,
|
||||
today = today,
|
||||
days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone),
|
||||
range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS),
|
||||
rangeIsOverride = false,
|
||||
rangeEnd = windowEnd,
|
||||
// The range bar belongs to the screen, not the list — the preview shows
|
||||
// the list only.
|
||||
showRangeBar = false,
|
||||
zone = zone,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import android.net.Uri
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.CircularProgressIndicator
|
||||
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.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||
import de.jeanlucmakiola.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,
|
||||
visible: Boolean = true,
|
||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.backupState.collectAsStateWithLifecycle()
|
||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// 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,
|
||||
// Loading and failure fill the screen and centre themselves, which they
|
||||
// can only do in an unscrolled column — the scrolling one measures them
|
||||
// against an unbounded height and leaves them hanging under the header.
|
||||
scrollable = state is BackupUiState.Ready,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
when (val s = state) {
|
||||
BackupUiState.Loading -> BackupLoading()
|
||||
is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack)
|
||||
is BackupUiState.Ready -> BackupContent(
|
||||
exportable = s.exportable,
|
||||
canImport = s.canImport,
|
||||
autoBackup = autoBackup,
|
||||
viewModel = viewModel,
|
||||
onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||
onPickFolder = { runCatching { pickFolder.launch(null) } },
|
||||
onExportAll = {
|
||||
runCatching { createBackup.launch(defaultBackupName()) }
|
||||
},
|
||||
onExportPick = { showExportPicker = true },
|
||||
onEditInterval = { showInterval = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Gated on Ready rather than resetting the flag when the state leaves it:
|
||||
// flipping it here would be a write during composition.
|
||||
(state as? BackupUiState.Ready)?.let { ready ->
|
||||
if (showExportPicker) {
|
||||
ExportCalendarPicker(
|
||||
calendars = ready.exportable,
|
||||
onExport = viewModel::exportBackup,
|
||||
onDismiss = { showExportPicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showInterval) {
|
||||
BackupIntervalDialog(
|
||||
currentMinutes = autoBackup.intervalMinutes,
|
||||
onConfirm = viewModel::setAutoBackupIntervalMinutes,
|
||||
onDismiss = { showInterval = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The screen's loading state, centred in the scaffold's content column. */
|
||||
@Composable
|
||||
private fun BackupLoading() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
/** Default file name for a one-shot export. */
|
||||
private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.ics"
|
||||
|
||||
/**
|
||||
* The working screen: export (when there are local calendars to export), restore,
|
||||
* and the automatic-backup block. Restore is always on screen — when nothing can
|
||||
* receive the events it says so and routes to the calendar manager rather than
|
||||
* disappearing, which is what #304 reported as an invisible button.
|
||||
*/
|
||||
@Composable
|
||||
private fun BackupContent(
|
||||
exportable: List<CalendarSource>,
|
||||
canImport: Boolean,
|
||||
autoBackup: AutoBackupUiState,
|
||||
viewModel: CalendarsViewModel,
|
||||
onRestore: () -> Unit,
|
||||
onPickFolder: () -> Unit,
|
||||
onExportAll: () -> Unit,
|
||||
onExportPick: () -> Unit,
|
||||
onEditInterval: () -> Unit,
|
||||
) {
|
||||
val manageCalendars = LocalManageCalendars.current
|
||||
// Restore never depends on export eligibility, and never disappears: when no
|
||||
// calendar can receive events it explains that and offers the way to fix it.
|
||||
val restoreSummary = if (canImport) {
|
||||
stringResource(R.string.calendars_restore_hint)
|
||||
} else {
|
||||
stringResource(R.string.calendars_restore_unavailable)
|
||||
}
|
||||
val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore)
|
||||
|
||||
if (exportable.isEmpty()) {
|
||||
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||
HintText(restoreSummary)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
position = Position.Alone,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = onRestoreClick,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_backup_action),
|
||||
position = Position.Top,
|
||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||
// A single exportable calendar skips the selector.
|
||||
onClick = if (exportable.size == 1) onExportAll else onExportPick,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
summary = restoreSummary,
|
||||
position = Position.Middle,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = onRestoreClick,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup),
|
||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||
trailing = {
|
||||
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
|
||||
},
|
||||
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
|
||||
)
|
||||
if (autoBackup.enabled) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_folder),
|
||||
summary = rememberFolderName(autoBackup.folderUri)
|
||||
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||
position = Position.Middle,
|
||||
onClick = onPickFolder,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = onEditInterval,
|
||||
)
|
||||
HintText(backupStatusText(autoBackup.status))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
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,53 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||
|
||||
/**
|
||||
* State of the Backup & restore screen (#69). Three states, because the calendar
|
||||
* list it is derived from has all three: it arrives empty, it can throw, and
|
||||
* "loaded but nothing to offer" is a real outcome the screen used to render as a
|
||||
* blank page (#304).
|
||||
*/
|
||||
sealed interface BackupUiState {
|
||||
data object Loading : BackupUiState
|
||||
data class Failure(val reason: FailureReason) : BackupUiState
|
||||
|
||||
/**
|
||||
* At least one half of the screen works. [exportable] may be empty (restore
|
||||
* only) and [canImport] may be false (export only), but never both — that is
|
||||
* a [Failure].
|
||||
*/
|
||||
data class Ready(
|
||||
val exportable: List<CalendarSource>,
|
||||
val canImport: Boolean,
|
||||
) : BackupUiState
|
||||
}
|
||||
|
||||
/**
|
||||
* What the screen can offer for this calendar list.
|
||||
*
|
||||
* Export covers the app's own local calendars; managed special-dates mirrors are
|
||||
* rebuilt from contacts, so they are excluded. Restore can target anything the
|
||||
* import picker offers ([isEventTarget]).
|
||||
*
|
||||
* A failure is raised only when *neither* is possible — deliberately not
|
||||
* [calendarListFailure] on its own, which would call an all-hidden device a
|
||||
* failure while its local calendars are still perfectly exportable. When both
|
||||
* halves are dead the list itself usually says why (no calendars, everything
|
||||
* switched off); [FailureReason.NoImportTarget] covers the remaining case, where
|
||||
* calendars exist and are visible but every one of them is read-only, managed or
|
||||
* not synced to the device.
|
||||
*/
|
||||
fun backupUiState(calendars: List<CalendarSource>): BackupUiState {
|
||||
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||
val canImport = calendars.any { it.isEventTarget }
|
||||
if (exportable.isEmpty() && !canImport) {
|
||||
return BackupUiState.Failure(
|
||||
calendarListFailure(calendars) ?: FailureReason.NoImportTarget,
|
||||
)
|
||||
}
|
||||
return BackupUiState.Ready(exportable = exportable, canImport = canImport)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,11 +12,9 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier
|
||||
import de.jeanlucmakiola.calendula.data.reminders.SuppressedReminderStore
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -27,6 +25,7 @@ import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -45,10 +44,7 @@ class CalendarsViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val repository: CalendarRepository,
|
||||
private val icsExporter: IcsExporter,
|
||||
private val prefs: CalendarPrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val suppressedStore: SuppressedReminderStore,
|
||||
private val notifier: ReminderNotifier,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -63,17 +59,19 @@ class CalendarsViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The Backup & restore screen's own view of that list, with the loading and
|
||||
* failure states [calendars] flattens away — it starts empty and catches to
|
||||
* empty, which that screen used to render as a blank page (#304).
|
||||
*/
|
||||
val disabledCalendarIds: StateFlow<Set<Long>> =
|
||||
prefs.disabledCalendarIds
|
||||
val backupState: StateFlow<BackupUiState> =
|
||||
repository.calendars()
|
||||
.map { backupUiState(it) }
|
||||
.catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptySet(),
|
||||
initialValue = BackupUiState.Loading,
|
||||
)
|
||||
|
||||
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||
@@ -92,6 +90,25 @@ class CalendarsViewModel @Inject constructor(
|
||||
initialValue = AutoBackupUiState(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Managed special-dates calendars whose deletion would not stick: while the
|
||||
* feature is on, `SpecialDatesSyncEngine.reconcileCalendars` undoes it on
|
||||
* the next pass. Read off each calendar's durable [CalendarSource.isManaged]
|
||||
* marker rather than the stored ids, which lag a sync pass behind.
|
||||
*/
|
||||
val deleteLockedCalendarIds: StateFlow<Set<Long>> = combine(
|
||||
calendars,
|
||||
settingsPrefs.specialDatesEnabled,
|
||||
) { sources, enabled ->
|
||||
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
private val _error = MutableStateFlow(false)
|
||||
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||
|
||||
@@ -140,54 +157,21 @@ class CalendarsViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable a calendar app-side. Disabling removes it from every
|
||||
* surface but Settings → Calendars (and hides its events) without touching
|
||||
* provider data — purely a reversible Calendula-local view choice.
|
||||
* Switch a calendar on or off — the app's one visibility model, writing the
|
||||
* system's `Calendars.VISIBLE`. A reminder that came due while the calendar
|
||||
* was off stays gone when it is switched back on: the watermark has already
|
||||
* moved past it (#75).
|
||||
*/
|
||||
fun setDisabled(id: Long, disabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val current = prefs.disabledCalendarIds.first()
|
||||
val next = if (disabled) current + id else current - id
|
||||
if (next != current) {
|
||||
prefs.setDisabledCalendarIds(next)
|
||||
if (!disabled) recoverReminders(setOf(id))
|
||||
}
|
||||
}
|
||||
fun setCalendarVisible(id: Long, visible: Boolean) = write {
|
||||
repository.setCalendarsVisible(listOf(id), visible)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable every calendar of one account in a single write — the
|
||||
* "toggle all" affordance on an account header. Done as one set update so the
|
||||
* per-calendar [setDisabled] calls can't race each other.
|
||||
* Switch every calendar of one account on or off. Each row is written on its
|
||||
* own, in one coroutine so the writes can't race.
|
||||
*/
|
||||
fun setAccountDisabled(ids: Collection<Long>, disabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val current = prefs.disabledCalendarIds.first()
|
||||
val next = if (disabled) current + ids else current - ids.toSet()
|
||||
if (next != current) {
|
||||
prefs.setDisabledCalendarIds(next)
|
||||
if (!disabled) recoverReminders(current intersect ids.toSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-post the reminders that fired while [reEnabledIds] were disabled and are
|
||||
* still relevant (event not yet over), then drop them from the stash. Runs
|
||||
* after the disabled set is written, so the notifier's own disabled gate lets
|
||||
* them through. Best-effort at re-enable time: it mirrors the receiver gates
|
||||
* (reminders on + postable), and there is no later re-scan, so alerts left
|
||||
* unposted because those gates are closed are simply released.
|
||||
*/
|
||||
private suspend fun recoverReminders(reEnabledIds: Set<Long>) {
|
||||
if (reEnabledIds.isEmpty()) return
|
||||
val recovered = suppressedStore.recoverFor(reEnabledIds, System.currentTimeMillis())
|
||||
if (recovered.isNotEmpty() &&
|
||||
settingsPrefs.remindersEnabled.first() &&
|
||||
notifier.canPost()
|
||||
) {
|
||||
recovered.forEach { notifier.post(it) }
|
||||
}
|
||||
fun setAccountVisible(ids: Collection<Long>, visible: Boolean) = write {
|
||||
repository.setCalendarsVisible(ids, visible)
|
||||
}
|
||||
|
||||
// --- Automatic backup (issue #8) ------------------------------------
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
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 {
|
||||
val label = group.label.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||
return if (!group.ambiguous) {
|
||||
label
|
||||
} else {
|
||||
stringResource(R.string.calendars_account_from_source, 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Trailing-edge and title spacing shared by the four calendar app bars (#165).
|
||||
*
|
||||
* Only the trailing edge and the title's start are shared; the leading edge and
|
||||
* the gaps between the action icons stay on M3's defaults, which derive both
|
||||
* from the icon buttons' width.
|
||||
*/
|
||||
object AppBarSpacing {
|
||||
|
||||
/** Side inset shared by the bars' trailing edge and the month grid. */
|
||||
val Inset = 8.dp
|
||||
|
||||
/**
|
||||
* Start inset on the title, taking it clear of the navigation icon M3 places
|
||||
* it flush against. Real padding rather than a negative offset, which would
|
||||
* swallow taps meant for the menu button.
|
||||
*/
|
||||
val TitleInset = 8.dp
|
||||
|
||||
/** M3's own padding around the actions row. */
|
||||
internal val BarPadding = 4.dp
|
||||
|
||||
private val IconButtonSize = 48.dp
|
||||
|
||||
/** M3's icon slot inside an icon button; the today glyph fills the same box. */
|
||||
internal val IconSize = 24.dp
|
||||
|
||||
/**
|
||||
* End padding for a container-backed control that ends the bar, measured to
|
||||
* its background. Coerced because [androidx.compose.foundation.layout.padding]
|
||||
* throws on a negative value.
|
||||
*/
|
||||
val ContainerTrailingInset = (Inset - BarPadding).coerceAtLeast(0.dp)
|
||||
|
||||
/** Screen edge to the glyph of an icon button that ends the bar. */
|
||||
val IconTrailingInset = BarPadding + (IconButtonSize - IconSize) / 2
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.ParentDataModifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Gap a timed block leaves to its neighbours in the column. */
|
||||
val BLOCK_OUTER_INSET = 1.dp
|
||||
|
||||
/** Padding between a timed block's edge and its text. */
|
||||
val BLOCK_TEXT_PADDING = 4.dp
|
||||
|
||||
/** The same, above and below — what a block's height has to pay before any text. */
|
||||
val BLOCK_TEXT_INSET = 2.dp
|
||||
|
||||
/**
|
||||
* What a timed block of a given height has to spend on text, and what each line
|
||||
* of it costs.
|
||||
*/
|
||||
@Immutable
|
||||
data class BlockTextMetrics(
|
||||
/** Padding above and below the text — see [rememberBlockTextMetrics]. */
|
||||
val inset: Dp,
|
||||
/** Height left for text once [inset] is paid at both edges. */
|
||||
val available: Dp,
|
||||
/** What the first line of a title draws in. */
|
||||
val titleLine: Dp,
|
||||
/** What every title line after the first adds. */
|
||||
val titleLeading: Dp,
|
||||
/** What the time label's one line draws in. */
|
||||
val timeLine: Dp,
|
||||
) {
|
||||
/** Whether the block can draw a title at all. */
|
||||
val fitsTitle: Boolean get() = available >= titleLine
|
||||
|
||||
/** Height a title of [lines] lines occupies. */
|
||||
fun titleHeight(lines: Int): Dp =
|
||||
if (lines <= 0) 0.dp else titleLine + titleLeading * (lines - 1)
|
||||
|
||||
/** Title lines that fit [within], which may be none. */
|
||||
fun titleBudget(within: Dp): Int =
|
||||
if (within < titleLine) 0 else 1 + ((within - titleLine) / titleLeading).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* The vertical padding a block [height] tall can afford around a title line of
|
||||
* [titleLine].
|
||||
*
|
||||
* The inset is what the block gives up first: breathing room is worth having
|
||||
* where there is room to breathe, but on a block down to its last few pixels a
|
||||
* bare colour chip where a label would have fit reads as a rendering fault. It
|
||||
* tapers rather than snapping, so a pinch closes the gap gradually instead of
|
||||
* dropping it in one frame (#289).
|
||||
*/
|
||||
internal fun blockTextInset(height: Dp, titleLine: Dp): Dp =
|
||||
minOf(BLOCK_TEXT_INSET, (height - titleLine) / 2).coerceAtLeast(0.dp)
|
||||
|
||||
/** Text metrics for a timed block [height] tall. */
|
||||
@Composable
|
||||
fun rememberBlockTextMetrics(height: Dp): BlockTextMetrics {
|
||||
val titleStyle = MaterialTheme.typography.labelMedium
|
||||
val titleLine = rememberTrimmedLineHeight(titleStyle)
|
||||
// Packed, so a second line costs what the first did rather than a whole
|
||||
// Material line box — the gap between two lines of a wrapped title is the
|
||||
// one place a block pays that leading twice (#190).
|
||||
val titleLeading = titleLine
|
||||
val timeLine = rememberTrimmedLineHeight(MaterialTheme.typography.labelSmall.asEventTime())
|
||||
val inset = blockTextInset(height, titleLine)
|
||||
return BlockTextMetrics(
|
||||
inset = inset,
|
||||
available = height - inset * 2,
|
||||
titleLine = titleLine,
|
||||
titleLeading = titleLeading,
|
||||
timeLine = timeLine,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lines [text] needs to render whole at [textWidth], capped at [max].
|
||||
*
|
||||
* Lets a block hand out its height by what the text actually asks for rather
|
||||
* than by what would fit: a title that wants one line should not be given three
|
||||
* that the time label could have used, and a week column is narrower than a
|
||||
* "09:30–11:00" range so the range should not be assumed to want one.
|
||||
*/
|
||||
@Composable
|
||||
fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
|
||||
return remember(text, style, widthPx, max, measurer) {
|
||||
if (max <= 1 || widthPx <= 0) {
|
||||
1
|
||||
} else {
|
||||
measurer.measure(
|
||||
text = text,
|
||||
style = style,
|
||||
constraints = Constraints(maxWidth = widthPx),
|
||||
).lineCount.coerceIn(1, max)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lines the time label may take at [textWidth], out of the [spare] height left
|
||||
* once the title and the label's own first line are paid for.
|
||||
*
|
||||
* A week column is narrower than a "09:30–11:00" range — and a 12-hour
|
||||
* "6:00 PM–7:00 PM" can want four lines — so the label wraps over as many as it
|
||||
* needs rather than lose its end, but only out of lines the title measured
|
||||
* itself as not needing, never one it would have filled.
|
||||
*/
|
||||
@Composable
|
||||
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
|
||||
val timeLineHeight = rememberTrimmedLineHeight(
|
||||
MaterialTheme.typography.labelSmall.asEventTime(),
|
||||
)
|
||||
return blockTextLines(
|
||||
text = label,
|
||||
// The style it is drawn in, or the budget measures a line the label
|
||||
// never uses (#219).
|
||||
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||
textWidth = textWidth,
|
||||
max = timeLinesWithin(spare, timeLineHeight),
|
||||
)
|
||||
}
|
||||
|
||||
/** Lines a time label may take: its own first one, plus every whole [timeLine] in [spare]. */
|
||||
internal fun timeLinesWithin(spare: Dp, timeLine: Dp): Int =
|
||||
if (spare < timeLine || timeLine <= 0.dp) 1 else 1 + (spare / timeLine).toInt()
|
||||
|
||||
/**
|
||||
* The time range as a timed block draws it.
|
||||
*
|
||||
* Each time stays whole where the column is wide enough for one, so a 12-hour
|
||||
* label never parts "6:00" from its "PM": the range breaks at the dash, which
|
||||
* rides on whichever side has room for it. Where not even one time fits, the
|
||||
* text wraps wherever it can.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberBlockTimeRange(start: String, end: String, textWidth: Dp): String {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val style = MaterialTheme.typography.labelSmall.asEventTime()
|
||||
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
|
||||
return remember(start, end, style, widthPx, measurer) {
|
||||
blockTimeRange(start, end) {
|
||||
measurer.measure(text = it, style = style, softWrap = false).size.width <= widthPx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun blockTimeRange(start: String, end: String, fits: (String) -> Boolean): String {
|
||||
val from = start.replace(' ', NO_BREAK_SPACE)
|
||||
val to = end.replace(' ', NO_BREAK_SPACE)
|
||||
return when {
|
||||
fits("$from–$to") -> "$from–$to"
|
||||
!fits(from) || !fits(to) -> "$start–$ZERO_WIDTH_SPACE$end"
|
||||
fits("$from–") -> "$from–\n$to"
|
||||
fits("–$to") -> "$from\n–$to"
|
||||
else -> "$from\n$to"
|
||||
}
|
||||
}
|
||||
|
||||
private const val NO_BREAK_SPACE = ' '
|
||||
private const val ZERO_WIDTH_SPACE = ''
|
||||
|
||||
/**
|
||||
* A timed block's title, over at most [maxLines], breaking at word boundaries.
|
||||
*
|
||||
* #164 clipped the last line mid-glyph so none of a narrow chip's few
|
||||
* characters went on an ellipsis. On a block that can wrap, whole words read
|
||||
* better than full lines do: "Farmers Market" over two lines beats "Farmer" /
|
||||
* "s Marke". A single line still clips, having nowhere to wrap to.
|
||||
*/
|
||||
@Composable
|
||||
fun BlockTitle(
|
||||
title: String,
|
||||
maxLines: Int,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
textDecoration: TextDecoration? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
) {
|
||||
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
|
||||
Text(
|
||||
text = title,
|
||||
modifier = modifier,
|
||||
style = rememberPackedLines(
|
||||
MaterialTheme.typography.labelMedium
|
||||
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
|
||||
),
|
||||
maxLines = maxLines,
|
||||
overflow = overflow.overflow,
|
||||
softWrap = overflow.softWrap,
|
||||
color = color,
|
||||
textDecoration = textDecoration,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A timed block's own time label, crossfaded rather than replaced — the block
|
||||
* slides to its new slot, so the label shouldn't change in a single frame.
|
||||
*
|
||||
* Overflows like a title does (#164): the "…" costs two characters of a string
|
||||
* that is nothing but characters, so the label clips at the block's edge
|
||||
* instead. [maxLines] lets a narrow column spend spare height on the range
|
||||
* rather than losing its end.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun BlockTimeLabel(
|
||||
label: String,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
maxLines: Int = 1,
|
||||
) {
|
||||
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
|
||||
snap()
|
||||
} else {
|
||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||
}
|
||||
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
|
||||
// Regular weight against the title's medium above it (#219).
|
||||
val style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime())
|
||||
Crossfade(
|
||||
targetState = label,
|
||||
animationSpec = spec,
|
||||
label = "block-time",
|
||||
modifier = modifier,
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = style,
|
||||
maxLines = maxLines,
|
||||
overflow = overflow.overflow,
|
||||
softWrap = overflow.softWrap,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a timed block sits in its column, after tweening: across as shares of
|
||||
* the column's width, so a column resizing carries its blocks with it (#184),
|
||||
* down in the timeline's own units.
|
||||
*/
|
||||
@Immutable
|
||||
data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y: Dp, val height: Dp)
|
||||
|
||||
/**
|
||||
* A timed block's placement, tweened rather than jumped. Continuity comes from
|
||||
* the caller keying each block by identity; a block composed for the first time
|
||||
* starts at its target, so nothing flies in on the first frame.
|
||||
*
|
||||
* A pinch-zoom rewrites the hour height every pointer frame, and the gutter and
|
||||
* grid lines follow it instantly — so the tween stands down for the gesture
|
||||
* rather than leaving the blocks trailing the ruler they are measured against.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement {
|
||||
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
|
||||
val dpSpec: FiniteAnimationSpec<Dp> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
val shareSpec: FiniteAnimationSpec<Float> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
|
||||
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
|
||||
val animatedY by animateDpAsState(y, dpSpec, label = "block-y")
|
||||
val animatedHeight by animateDpAsState(height, dpSpec, label = "block-height")
|
||||
return BlockPlacement(animatedX, animatedWidth, animatedY, animatedHeight)
|
||||
}
|
||||
|
||||
/** Seats a block across its [placement]'s share of a [LaneColumn]. */
|
||||
fun Modifier.laneSlot(placement: BlockPlacement): Modifier =
|
||||
then(LaneSlot(placement.xFraction, placement.widthFraction))
|
||||
|
||||
private data class LaneSlot(val x: Float, val width: Float) : ParentDataModifier {
|
||||
override fun Density.modifyParentData(parentData: Any?): Any = this@LaneSlot
|
||||
}
|
||||
|
||||
/**
|
||||
* A day column's blocks, each measured across the share of the column its
|
||||
* [laneSlot] gives it; anything else fills the column. The shares are applied
|
||||
* at layout time, so the column can change width every frame without its
|
||||
* blocks recomposing.
|
||||
*/
|
||||
@Composable
|
||||
fun LaneColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||
Layout(content = content, modifier = modifier) { measurables, constraints ->
|
||||
val width = constraints.maxWidth
|
||||
val loose = constraints.copy(minWidth = 0, minHeight = 0)
|
||||
val placed = measurables.map { measurable ->
|
||||
val slot = measurable.parentData as? LaneSlot
|
||||
if (slot == null) {
|
||||
measurable.measure(loose) to 0
|
||||
} else {
|
||||
val left = (slot.x * width).roundToInt()
|
||||
val right = ((slot.x + slot.width) * width).roundToInt()
|
||||
val laneWidth = (right - left).coerceAtLeast(0)
|
||||
measurable.measure(loose.copy(minWidth = laneWidth, maxWidth = laneWidth)) to left
|
||||
}
|
||||
}
|
||||
layout(width, constraints.maxHeight) {
|
||||
placed.forEach { (placeable, x) -> placeable.placeRelative(x, 0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* What a calendar screen shows in the top bar, the FAB and the drawer, which the
|
||||
* host draws once for all of them (#184): switching screens changes what they
|
||||
* say rather than swapping one bar for another.
|
||||
*
|
||||
* @param titleDate the date the title's picker opens on, or null for a title
|
||||
* that is only a label.
|
||||
* @param drawerDate the date the drawer's picker opens on.
|
||||
* @param awayFromToday whether "today" is worth offering.
|
||||
*/
|
||||
@Immutable
|
||||
data class ScreenChrome(
|
||||
val title: String,
|
||||
val shortTitle: String = title,
|
||||
val titleDate: LocalDate?,
|
||||
val drawerDate: LocalDate,
|
||||
val awayFromToday: Boolean,
|
||||
val todayText: String,
|
||||
)
|
||||
|
||||
/** What the chrome does for the screen on show; read when used, so not observed. */
|
||||
class ScreenActions(
|
||||
val onToday: () -> Unit,
|
||||
val onJumpToDate: (LocalDate) -> Unit,
|
||||
val onCreate: () -> Unit,
|
||||
)
|
||||
|
||||
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
|
||||
@Stable
|
||||
class ChromeHost {
|
||||
var chrome by mutableStateOf<ScreenChrome?>(null)
|
||||
private set
|
||||
|
||||
var actions: ScreenActions? = null
|
||||
private set
|
||||
|
||||
internal fun publish(chrome: ScreenChrome, actions: ScreenActions) {
|
||||
this.chrome = chrome
|
||||
this.actions = actions
|
||||
}
|
||||
}
|
||||
|
||||
/** The host's chrome, and whether the screen reading it is the one on show rather than one leaving. */
|
||||
class ChromeSlot(val host: ChromeHost, val current: Boolean)
|
||||
|
||||
val LocalChromeSlot = staticCompositionLocalOf<ChromeSlot?> { null }
|
||||
|
||||
/** Hands [chrome] and [actions] to the host, while this screen is the one on show. */
|
||||
@Composable
|
||||
fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
|
||||
val slot = LocalChromeSlot.current ?: return
|
||||
if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
|
||||
}
|
||||
|
||||
/** The top bar every calendar screen shares. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CalendarTopBar(
|
||||
host: ChromeHost,
|
||||
selectedView: CalendarView,
|
||||
quickSwitchViews: List<CalendarView>,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
) {
|
||||
val chrome = host.chrome
|
||||
TopAppBar(
|
||||
title = {
|
||||
if (chrome != null) {
|
||||
CalendarTitleButton(
|
||||
title = chrome.title,
|
||||
currentDate = chrome.titleDate,
|
||||
onJumpToDate = { host.actions?.onJumpToDate?.invoke(it) },
|
||||
shortTitle = chrome.shortTitle,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(R.string.month_open_menu),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TodayAction(show = showTodayButton, onToday = { host.actions?.onToday?.invoke() })
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = stringResource(R.string.search_action),
|
||||
)
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
cycle = quickSwitchViews,
|
||||
onCycle = onCycleView,
|
||||
)
|
||||
},
|
||||
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||
// it, but here the bar meets the header on the same surface and the
|
||||
// tint is what makes that seam look like a separate block (#186).
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The FAB column every calendar screen shares. */
|
||||
@Composable
|
||||
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) {
|
||||
val chrome = host.chrome ?: return
|
||||
CalendarFabColumn(
|
||||
todayVisible = chrome.awayFromToday && !todayInToolbar,
|
||||
todayText = chrome.todayText,
|
||||
onToday = { host.actions?.onToday?.invoke() },
|
||||
onCreate = { host.actions?.onCreate?.invoke() },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A calendar screen's body under the shared chrome, on the ground the screen's
|
||||
* own Scaffold used to give it; the host's Scaffold has already made room for
|
||||
* the bars, so [content] gets no padding of its own.
|
||||
*/
|
||||
@Composable
|
||||
fun ScreenBody(modifier: Modifier = Modifier, content: @Composable (PaddingValues) -> Unit) {
|
||||
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
content(PaddingValues())
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
||||
Icon(
|
||||
Icons.Filled.CalendarMonth,
|
||||
contentDescription = null,
|
||||
tint = eventFill(color, dark, soften),
|
||||
tint = eventAccent(color, dark, soften),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -19,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.DrawerState
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
@@ -27,19 +29,23 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
@@ -50,7 +56,8 @@ import kotlinx.datetime.LocalDate
|
||||
* a jump-to-date action, the per-calendar visibility filter (M3) inline, and a
|
||||
* pinned Settings row. The "View" section mirrors the top-bar switcher pill —
|
||||
* tapping a view here selects it (and closes the drawer) rather than cycling.
|
||||
* The host screen owns the drawer state.
|
||||
* The host screen owns the drawer state; the sheet reads it only to dismiss
|
||||
* itself on back.
|
||||
*
|
||||
* [currentDate] seeds the jump-to-date picker (the visible day/week-start/month
|
||||
* anchor); [onJumpToDate] navigates the active view to the chosen day.
|
||||
@@ -59,12 +66,17 @@ import kotlinx.datetime.LocalDate
|
||||
fun CalendarDrawer(
|
||||
currentView: CalendarView,
|
||||
currentDate: LocalDate,
|
||||
drawerState: DrawerState,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
) {
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Registered in the sheet so it takes precedence over the host's back handler.
|
||||
BackHandler(enabled = drawerState.isOpen) { scope.launch { drawerState.close() } }
|
||||
|
||||
ModalDrawerSheet {
|
||||
// The whole sidebar scrolls as one — header, views, the calendar filter
|
||||
@@ -79,11 +91,11 @@ fun CalendarDrawer(
|
||||
DrawerSectionHeader(stringResource(R.string.view_section))
|
||||
viewOrder.forEachIndexed { index, view ->
|
||||
GroupedRow(
|
||||
title = stringResource(view.labelRes),
|
||||
title = view.label(),
|
||||
position = positionOf(index, viewOrder.size),
|
||||
selected = view == currentView,
|
||||
minHeight = 56.dp,
|
||||
leading = { Icon(view.icon, contentDescription = null) },
|
||||
leading = { DrawerLeadingIcon(view.icon) },
|
||||
onClick = { onSelectView(view) },
|
||||
)
|
||||
}
|
||||
@@ -93,7 +105,7 @@ fun CalendarDrawer(
|
||||
title = stringResource(R.string.drawer_jump_to_date),
|
||||
position = Position.Alone,
|
||||
minHeight = 56.dp,
|
||||
leading = { Icon(Icons.Filled.DateRange, contentDescription = null) },
|
||||
leading = { DrawerLeadingIcon(Icons.Filled.DateRange) },
|
||||
onClick = { showDatePicker = true },
|
||||
)
|
||||
|
||||
@@ -107,7 +119,7 @@ fun CalendarDrawer(
|
||||
title = stringResource(R.string.month_action_settings),
|
||||
position = Position.Alone,
|
||||
minHeight = 56.dp,
|
||||
leading = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
||||
leading = { DrawerLeadingIcon(Icons.Filled.Settings) },
|
||||
onClick = onSettings,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -126,13 +138,27 @@ fun CalendarDrawer(
|
||||
}
|
||||
}
|
||||
|
||||
/** Leading slot for the drawer's plain icons: the same 40.dp footprint
|
||||
* [CalendarColorChip] takes, so every leading glyph shares one vertical axis. */
|
||||
@Composable
|
||||
private fun DrawerLeadingIcon(icon: ImageVector) {
|
||||
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(icon, contentDescription = null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Branded header: the app-icon chip beside the app name. */
|
||||
@Composable
|
||||
private fun DrawerHeader() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 28.dp, top = 24.dp, bottom = 16.dp),
|
||||
.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 24.dp,
|
||||
bottom = 16.dp,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
@@ -164,6 +190,11 @@ private fun DrawerSectionHeader(text: String) {
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 16.dp,
|
||||
bottom = 8.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,40 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.CalendarMonth
|
||||
import androidx.compose.material.icons.outlined.EditOff
|
||||
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||
import androidx.compose.material.icons.outlined.EventBusy
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.SyncProblem
|
||||
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -20,37 +43,168 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
|
||||
/**
|
||||
* Full-screen failure state shared by every calendar screen (spec §7).
|
||||
* One explanation line + one recovery action, never a toast.
|
||||
* A tonal icon, one headline, one supporting line and one recovery action —
|
||||
* never a toast.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
|
||||
val titleRes = when (reason) {
|
||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
||||
FailureReason.ProviderUnavailable -> R.string.state_failure_provider
|
||||
FailureReason.Unknown,
|
||||
FailureReason.EventNotFound -> R.string.state_failure_unknown
|
||||
}
|
||||
val actionRes = when (reason) {
|
||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
|
||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
|
||||
else -> R.string.state_retry
|
||||
fun CalendarFailure(
|
||||
reason: FailureReason,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val manageCalendars = LocalManageCalendars.current
|
||||
val copy = failureCopy(reason)
|
||||
val onAction: () -> Unit = when (reason) {
|
||||
FailureReason.NoCalendarsConfigured -> {
|
||||
{ context.startCalendarSetup() }
|
||||
}
|
||||
FailureReason.AllCalendarsHidden,
|
||||
FailureReason.NoImportTarget,
|
||||
-> manageCalendars ?: onRetry
|
||||
else -> onRetry
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
.padding(horizontal = 32.dp, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
FailureIcon(icon = copy.icon, isError = copy.isError)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = stringResource(titleRes),
|
||||
text = stringResource(copy.title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
FilledTonalButton(onClick = onRetry) {
|
||||
Text(stringResource(actionRes))
|
||||
copy.body?.let { body ->
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(32.dp))
|
||||
FilledTonalButton(onClick = onAction) {
|
||||
Text(stringResource(copy.action))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The state's mark: the reason's icon in a tonal circle above the headline. */
|
||||
@Composable
|
||||
private fun FailureIcon(icon: ImageVector, isError: Boolean) {
|
||||
val container: Color
|
||||
val content: Color
|
||||
if (isError) {
|
||||
container = MaterialTheme.colorScheme.errorContainer
|
||||
content = MaterialTheme.colorScheme.onErrorContainer
|
||||
} else {
|
||||
container = MaterialTheme.colorScheme.secondaryContainer
|
||||
content = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.background(container, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = content,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a reason says and offers. Headline and supporting line are separate so
|
||||
* the explanation reads as body text instead of a headline that runs over three
|
||||
* lines; [isError] tints the mark for the two reasons that are an actual fault
|
||||
* rather than a calendar the user can switch back on.
|
||||
*/
|
||||
private data class FailureCopy(
|
||||
val icon: ImageVector,
|
||||
@StringRes val title: Int,
|
||||
@StringRes val body: Int?,
|
||||
@StringRes val action: Int,
|
||||
val isError: Boolean = false,
|
||||
)
|
||||
|
||||
private fun failureCopy(reason: FailureReason): FailureCopy = when (reason) {
|
||||
FailureReason.PermissionRevoked -> FailureCopy(
|
||||
icon = Icons.Outlined.Lock,
|
||||
title = R.string.state_failure_permission,
|
||||
body = R.string.state_failure_permission_body,
|
||||
action = R.string.state_failure_permission_action,
|
||||
)
|
||||
FailureReason.NoCalendarsConfigured -> FailureCopy(
|
||||
icon = Icons.Outlined.CalendarMonth,
|
||||
title = R.string.state_failure_no_calendars,
|
||||
body = R.string.state_failure_no_calendars_body,
|
||||
action = R.string.state_failure_no_calendars_action,
|
||||
)
|
||||
FailureReason.AllCalendarsHidden -> FailureCopy(
|
||||
icon = Icons.Outlined.VisibilityOff,
|
||||
title = R.string.state_failure_all_hidden,
|
||||
body = R.string.state_failure_all_hidden_body,
|
||||
action = R.string.state_failure_all_hidden_action,
|
||||
)
|
||||
FailureReason.NoImportTarget -> FailureCopy(
|
||||
icon = Icons.Outlined.EditOff,
|
||||
title = R.string.state_failure_no_import_target,
|
||||
body = R.string.state_failure_no_import_target_body,
|
||||
action = R.string.state_failure_all_hidden_action,
|
||||
)
|
||||
FailureReason.EventNotFound -> FailureCopy(
|
||||
icon = Icons.Outlined.EventBusy,
|
||||
title = R.string.state_failure_event_not_found,
|
||||
body = R.string.state_failure_event_not_found_body,
|
||||
action = R.string.state_retry,
|
||||
)
|
||||
FailureReason.ProviderUnavailable -> FailureCopy(
|
||||
icon = Icons.Outlined.SyncProblem,
|
||||
title = R.string.state_failure_provider,
|
||||
body = R.string.state_failure_provider_body,
|
||||
action = R.string.state_retry,
|
||||
isError = true,
|
||||
)
|
||||
FailureReason.Unknown -> FailureCopy(
|
||||
icon = Icons.Outlined.ErrorOutline,
|
||||
title = R.string.state_failure_unknown,
|
||||
body = null,
|
||||
action = R.string.state_retry,
|
||||
isError = true,
|
||||
)
|
||||
}
|
||||
|
||||
/** Keeps the headline and its supporting line at a readable measure. */
|
||||
private val TEXT_MAX_WIDTH = 320.dp
|
||||
|
||||
/**
|
||||
* Opens Settings → Calendars, the only screen that can switch a calendar back
|
||||
* on. Null outside the calendar host — screens without it never raise the
|
||||
* reason that needs it, and fall back to their own retry.
|
||||
*/
|
||||
val LocalManageCalendars = compositionLocalOf<(() -> Unit)?> { null }
|
||||
|
||||
/**
|
||||
* Open the system screen for putting a calendar on the device: the account
|
||||
* chooser filtered to calendar providers, falling back to account settings
|
||||
* where no chooser resolves. Both are system components, so nothing here needs
|
||||
* a `<queries>` entry.
|
||||
*/
|
||||
private fun Context.startCalendarSetup() {
|
||||
val intents = listOf(
|
||||
Intent(Settings.ACTION_ADD_ACCOUNT)
|
||||
.putExtra(Settings.EXTRA_AUTHORITIES, arrayOf(CalendarContract.AUTHORITY)),
|
||||
Intent(Settings.ACTION_SYNC_SETTINGS),
|
||||
)
|
||||
intents.firstOrNull { runCatching { startActivity(it) }.isSuccess }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlin.math.floor
|
||||
|
||||
/**
|
||||
* One height for every page of something that varies from page to page — the
|
||||
* timelines' all-day strip, the split grid's rows — blended between the two
|
||||
* pages a swipe sits between, so what sits below it moves with the finger rather
|
||||
* than jumping on settle. It follows the swipe directly and only springs when a
|
||||
* page's own height changes at rest. [fallback] stands in until the pages have
|
||||
* reported theirs, so it doesn't open from zero.
|
||||
*
|
||||
* @param heights each composed page's own height, by page index.
|
||||
*/
|
||||
@Composable
|
||||
fun pagedHeight(pagerState: PagerState, heights: Map<Int, Dp>, fallback: Dp): Dp {
|
||||
val currentFallback by rememberUpdatedState(fallback)
|
||||
val target by remember(pagerState, heights) {
|
||||
derivedStateOf {
|
||||
val position = pagerState.currentPage + pagerState.currentPageOffsetFraction
|
||||
val from = floor(position).toInt()
|
||||
val a = heights[from]
|
||||
val b = heights[from + 1]
|
||||
lerp(a ?: b ?: currentFallback, b ?: a ?: currentFallback, position - from)
|
||||
}
|
||||
}
|
||||
val height = remember { Animatable(target, Dp.VectorConverter) }
|
||||
LaunchedEffect(height) {
|
||||
snapshotFlow { target to pagerState.isScrollInProgress }.collectLatest { (h, scrolling) ->
|
||||
if (scrolling) height.snapTo(h) else height.animateTo(h)
|
||||
}
|
||||
}
|
||||
return height.value
|
||||
}
|
||||
|
||||
/** A deliberate jump (Today, jump-to-date): animated, or snapped under reduced motion. */
|
||||
suspend fun PagerState.jumpToPage(
|
||||
page: Int,
|
||||
reduceMotion: Boolean,
|
||||
spec: FiniteAnimationSpec<Float>,
|
||||
) {
|
||||
if (reduceMotion) scrollToPage(page) else animateScrollToPage(page, animationSpec = spec)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user