Compare commits
458 Commits
v2.7.5
...
renovate/a
| Author | SHA1 | Date | |
|---|---|---|---|
| e46c85d660 | |||
|
|
44233cff35 | ||
|
|
5764bd889b | ||
|
|
a8a83a39d0 | ||
|
|
9557ca73ed | ||
|
|
9767fbbbaf | ||
|
|
9054742503 | ||
|
|
3d62036d79 | ||
|
|
34a8e91ea5 | ||
|
|
0097a9c534 | ||
|
|
4e51e33356 | ||
|
|
e8657117d6 | ||
|
|
d5c53df6b5 | ||
|
|
2f4e05899a | ||
| 06653bf5b1 | |||
| e952393116 | |||
| 384ba39cf9 | |||
|
|
3fca28810b | ||
|
|
c988e1f028 | ||
|
|
a3d136f55d | ||
|
|
db7094c54e | ||
| c70412c782 | |||
| ac0c43f930 | |||
| 314236ac0c | |||
| c6e83fc071 | |||
| 4c1bfc052e | |||
| bba536394f | |||
| bb34973047 | |||
| 108a1890be | |||
| 521c0ffd78 | |||
| 33a37d4105 | |||
| 6ccc0cbed2 | |||
|
|
6e6ffce271 | ||
|
|
23386bb998 | ||
|
|
28b1568486 | ||
|
|
5127971a37 | ||
|
|
0562af8d66 | ||
|
|
e3d3728bbd | ||
|
|
8cc9d075fd | ||
|
|
ede6d967c1 | ||
| c30f6ab318 | |||
| dd2b96b5fd | |||
| c22658db4c | |||
| 7b3893ddc6 | |||
| 9e28fa4981 | |||
| 7549c8fe04 | |||
| 75f699b714 | |||
| 3e821c9092 | |||
| 7602f158a3 | |||
| 052335e842 | |||
| 28fb7404d8 | |||
| 6ab235e1d8 | |||
| fb90c94e37 | |||
| 7a05fc3ac1 | |||
| bf38dac5d7 | |||
| 1e9581528e | |||
| 279872aa3b | |||
| ff6c4b6a4d | |||
| 7fb01ab8d1 | |||
| bdb16d069e | |||
| beb8536e8a | |||
| 63ba69729e | |||
| 01a6c8bab8 | |||
| f70b33b864 | |||
| 0df420e551 | |||
| 488de490f9 | |||
| f15fffa799 | |||
| 1499b5803d | |||
| 39cc70be95 | |||
| f9619e74d9 | |||
| e691336b5e | |||
| 4cee160c12 | |||
| a7ae8ff912 | |||
| d23a1d5b6d | |||
| b417900ddd | |||
| 95f4d28bbb | |||
| 24b005126e | |||
| ee9d65068d | |||
| 6e3d10858f | |||
| 94a82c2f6c | |||
| 81dcbbdce7 | |||
| becb9a6710 | |||
| 542744e342 | |||
| 7510e1f9af | |||
| 498250650c | |||
| ada6976e85 | |||
| 6a454e0b33 | |||
| cd6ae31d0f | |||
| 463811056d | |||
| 98d76deee5 | |||
| 20e18768eb | |||
| a34f29bcf8 | |||
| 997296e21e | |||
| 94355bf340 | |||
| 722fc87259 | |||
| 30fcbfa59f | |||
| c956ac79ed | |||
| b90f07a816 | |||
| 7d357bad87 | |||
| 3ffe76412e | |||
| 91c4a84818 | |||
| ccd433fee1 | |||
| 03f6b7c8c1 | |||
| 426ddf27ee | |||
| 4148196a36 | |||
| 9a1d753abc | |||
| 1b29abed17 | |||
| 85d72ad051 | |||
| fd3bcdf2f7 | |||
| 46afa830b3 | |||
| 7de9b2f81b | |||
| 3ec46c8631 | |||
| 529dc9c374 | |||
| 60ef920b82 | |||
| 7b7b859fee | |||
| 53d42341c0 | |||
| b5c2930609 | |||
| 3feafe38f2 | |||
| 55ffaad94f | |||
|
|
5bdde146c1 | ||
|
|
97f87fb927 | ||
|
|
469166c0bb | ||
|
|
b10965babe | ||
|
|
e5c122a2ee | ||
|
|
3aefc5c8f8 | ||
|
|
ecc643ac81 | ||
| 8e4d0defa2 | |||
| 19baed9291 | |||
| 4ea68adf8b | |||
| f459f7d39c | |||
| c3ba8ccf64 | |||
| 7ed1d89b66 | |||
| 734bcae02d | |||
| 629896b74d | |||
| 6a49539ae5 | |||
| 01ac61185b | |||
| ca01f6e729 | |||
| 0588609c75 | |||
| ab3df639b9 | |||
| fefe1f3652 | |||
| 7634df3cff | |||
| 60938af9f0 | |||
| accf0ac142 | |||
| f90badfcd5 | |||
| 249606a358 | |||
| 5be5ff77d0 | |||
| 6519cbca79 | |||
| 50510a23da | |||
| 2279738371 | |||
| 3d59962112 | |||
| a96d8b6de9 | |||
| 94b9eeaa78 | |||
|
|
85fb091e22 | ||
| 2139f8729c | |||
| 98a48aa795 | |||
| d255d232b9 | |||
| 9999fcdd2a | |||
| 953ffdff97 | |||
| 5377a2b466 | |||
| e73148dc6c | |||
| a19772e3a7 | |||
| b3386eff43 | |||
| 79e74d9995 | |||
| 396a5610aa | |||
| b9329f6fb6 | |||
| 1b731a4ab0 | |||
| ab631365b2 | |||
| 9aa370d583 | |||
|
|
8cd75716ae | ||
|
|
4feccf2008 | ||
| 23e9a5a35b | |||
| 38a35be0f0 | |||
| f9f0572ec5 | |||
| 0221972e6d | |||
| 86ea72d56f | |||
| 114db7939c | |||
| 4503847c0d | |||
| 95c5fe76e6 | |||
| 34fa9c9c3e | |||
| 53793bfb68 | |||
| 9c7c8cb03a | |||
| 4fea176e28 | |||
| cf9492c7ba | |||
| e6def9e5f7 | |||
| 974185f354 | |||
| 6aacdd9111 | |||
| 4f263d00fe | |||
| bda002684c | |||
| b6bcd195b0 | |||
| 9fb4502ed0 | |||
| d398c72005 | |||
| 9d718e0f51 | |||
| 8ddc11159b | |||
| 2218c11d3f | |||
|
|
6736bae5ee | ||
| 60aa889c57 | |||
| 3c9767387b | |||
|
|
21d1726d7a | ||
| 1e7ee5b98a | |||
| a82df3f6d0 | |||
| e0e3eb73b9 | |||
| 8a7a0af207 | |||
| 71a652ce3b | |||
| 5fa6eac1ad | |||
| 21ba3fb7cf | |||
| 316c2b0b12 | |||
| ef77eab627 | |||
| 955d47ef12 | |||
| 8536774522 | |||
| a8aeae5f32 | |||
| 988ac009b5 | |||
| 494e486998 | |||
| 3c73028c80 | |||
| 2be1be19fe | |||
| 1f050c2be9 | |||
|
|
5771b603f2 | ||
|
|
5725989aff | ||
| 8b22e1b2af | |||
| a514b8b506 | |||
| 60fcc6b64c | |||
| d6bc660983 | |||
| b449fff77c | |||
| f0cc35f2ce | |||
| 2ce79942c4 | |||
| df426bb8df | |||
| 31f51554f9 | |||
| e967007bdc | |||
| 9f7427e72f | |||
| 79d9e0eaa0 | |||
| a98dd654a6 | |||
| a138e179dd | |||
| 94e3887345 | |||
| f440d385fa | |||
| 8fb4767888 | |||
| 993d74502f | |||
| 4a11c951ae | |||
| f25f308326 | |||
| bff683a403 | |||
| bb7954d026 | |||
| d9f4239729 | |||
| cf3b897305 | |||
| 87a78ca924 | |||
| 13debac340 | |||
| 76846420a2 | |||
| 65bd6c4254 | |||
| 1a3e4f501f | |||
| 974d65f619 | |||
|
|
721ca0d7e0 | ||
|
|
2556eba84b | ||
|
|
fc1ff97d22 | ||
|
|
b7f3d421ef | ||
|
|
2722b3427a | ||
|
|
e28c293d27 | ||
|
|
5cb2a836ab | ||
|
|
b5978e7a61 | ||
| c4a048adee | |||
|
|
2746ca5784 | ||
| fc9bd33992 | |||
| 2239c38ecc | |||
| 79ad70e0c0 | |||
| f168021f51 | |||
| a2b1e7792b | |||
| c6ce0830b1 | |||
| be13f97802 | |||
| f6d40de832 | |||
| fdee311658 | |||
| 1ff41153e9 | |||
| 49d04bcee0 | |||
| 5e470baf4e | |||
| c8a4f90404 | |||
| 0a89ff9d0e | |||
| 0718afd5a6 | |||
| 84e6402031 | |||
| 7fe59b36c3 | |||
| 8881559e67 | |||
| 9f7e93d5f8 | |||
| 3863e74857 | |||
| 44d056f31f | |||
| 5c513a1b19 | |||
| 037d05b171 | |||
| 86d4e11584 | |||
| 67a07a9346 | |||
| b8a420bdab | |||
| 442697d57c | |||
| 8e3a17fdc3 | |||
| e6dd627c85 | |||
|
|
5844539db2 | ||
|
|
2cc83d7714 | ||
|
|
dada1bbe72 | ||
|
|
e061cbe127 | ||
| 262b2b8273 | |||
| efca0c86c4 | |||
| 08cd95cfba | |||
| bfc9a0db39 | |||
| 08905990cc | |||
| b5895b190e | |||
| 37f0e22911 | |||
| 4e125e58d5 | |||
| 4c26450ad4 | |||
| 52816b327a | |||
| cbb241bc4d | |||
| e5aa90bdd6 | |||
| 580dc5a669 | |||
| b9e800e9fc | |||
| 40d70d9f2b | |||
| 14b19cd902 | |||
| 0fd579b19b | |||
| 030c3e6ce1 | |||
| e67960d352 | |||
| 22bbfef3e2 | |||
| 4da9abe05b | |||
| 4540faf2a0 | |||
| 3aecb19a0c | |||
| f7c0b8a957 | |||
| 9a8d9b2177 | |||
| 93ca38d974 | |||
| a38093cae3 | |||
|
|
67511ce9be | ||
|
|
2e90682874 | ||
|
|
2a4cb6a896 | ||
| 8d530c5681 | |||
| 225f4c3491 | |||
| 11578a5588 | |||
| 5d887524b1 | |||
| 405ff16233 | |||
| 048f407ba1 | |||
| 69460bc35a | |||
|
|
bf10deaf5c | ||
|
|
b9e861af62 | ||
|
|
2eadda937b | ||
|
|
71b90fa184 | ||
|
|
4e9cf88bd4 | ||
|
|
b999202629 | ||
|
|
6f0e13a4a2 | ||
|
|
9ef7b33800 | ||
|
|
d04b431f2c | ||
|
|
dbeeadff80 | ||
|
|
f4a169ef5f | ||
|
|
604099b3f2 | ||
|
|
a3e6541512 | ||
| a89560953d | |||
| 4e498de051 | |||
| f2fb3d6279 | |||
| c2d88e744e | |||
| 2ae4c818ba | |||
| 25ff7ce7ca | |||
| fb2a40114a | |||
| e1f79df67a | |||
| e258a4a7f2 | |||
| 3f27a47d1e | |||
| feb87a1e73 | |||
| 28e464a616 | |||
| 93df6d6d62 | |||
| 49ddb9437b | |||
| e1cf00999d | |||
| 2d0d707a7d | |||
| de1fe31223 | |||
| 273cfce969 | |||
| b6a45b7264 | |||
| c7ef52e426 | |||
| b1151b6516 | |||
| 1664625bc5 | |||
| e8b87446f7 | |||
| dc478516cd | |||
| 8945b877b2 | |||
| 1424f0ffc0 | |||
| 548e581554 | |||
| a6ae4d7e1d | |||
| 614f4f2d75 | |||
| e6736b049a | |||
| 301904be6d | |||
| 3ca7200624 | |||
| c30a153552 | |||
| bb92d02000 | |||
| cd703e9d54 | |||
| f27b0e269d | |||
| 0f31981d26 | |||
| 6471c48528 | |||
| f8a2569831 | |||
| 6ba7f38887 | |||
| 366059b012 | |||
| 3cf1439850 | |||
| b2bfdc0f42 | |||
| 7c9830deda | |||
| 5e98059388 | |||
| 53c0482760 | |||
| f8efa4d3e4 | |||
| 93cc264fbf | |||
| 585cdac4ab | |||
| 9994e8c534 | |||
| 766c2ffcf8 | |||
| add88fbadf | |||
| 09a1aecb76 | |||
| c91608e48a | |||
| 47e8036000 | |||
| 07a8faf2df | |||
| 54b059e744 | |||
| d0c01ab8c4 | |||
| bf10fee19f | |||
| 2e88c40eff | |||
| 26e538a9ba | |||
| 814daa8151 | |||
| 450fc7f7af | |||
| be6d10d126 | |||
| a1e8c05c7b | |||
| cecd0c2234 | |||
| 43eb5e23e8 | |||
| 946addbbcd | |||
| 8a63aa86a7 | |||
| 0ddbbb11e8 | |||
| 575b1c7eb7 | |||
| dd61e0ed37 | |||
| 376ad5f394 | |||
| 8a9e6f0ee8 | |||
| 913b49e962 | |||
| 96f4d4dc84 | |||
| f4789c535e | |||
| 00220c5f0e | |||
| b7a5f16c84 | |||
| 2c38912834 | |||
| a9c67a5827 | |||
| a69b161178 | |||
| 024deadf78 | |||
| 599c3c03e7 | |||
| 5d48c90408 | |||
| c6d15d0118 | |||
| 79e083b4d4 | |||
| 119a8afe8e | |||
| b537e143f8 | |||
| faf2f27eda | |||
| 3d8e3ca69e | |||
| 3b5c0be765 | |||
| 013efef29e | |||
| 5457a34282 | |||
| 82e93fda4c | |||
| 871cc34cab | |||
| 5b99da32b0 | |||
| b8a7191fbe | |||
| 6b0eb48056 | |||
| 572a4734ea | |||
| 7f8a9069c0 | |||
| 22c4eded96 | |||
| 9839f9cd38 | |||
| c0e27133b4 | |||
| a4e0ec3fde | |||
| ac17343747 | |||
| b0f34ff18a | |||
| ab8a9e3401 | |||
| 92ec718b28 | |||
| 4ad1c09f8f | |||
| 5e8cb686e9 | |||
| 62ebd48e3c | |||
| c3dbcca989 | |||
| 37452be3bd | |||
| 006cce96a9 | |||
| 88d770bebe | |||
| b6bee55fc6 | |||
| 7ab277b434 |
30
.forgejo/ISSUE_TEMPLATE/bug_report.md
Normal file
30
.forgejo/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Something doesn't work the way it should
|
||||
title: ""
|
||||
labels:
|
||||
- bug
|
||||
---
|
||||
|
||||
### What happened
|
||||
|
||||
|
||||
### What you expected
|
||||
|
||||
|
||||
### Steps to reproduce
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Environment
|
||||
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||
- Android version:
|
||||
- Device:
|
||||
- Installed from: <!-- official F-Droid / the self-hosted repo / built from source -->
|
||||
- Affected calendar: <!-- Google, CalDAV (DAVx5, Nextcloud, …), on-device/local,
|
||||
subscribed/WebCal, birthdays — provider behaviour differs
|
||||
a lot per account type, so this often points straight at
|
||||
the cause -->
|
||||
- Time zone: <!-- only if the problem involves dates or all-day events -->
|
||||
|
||||
18
.forgejo/ISSUE_TEMPLATE/config.yml
Normal file
18
.forgejo/ISSUE_TEMPLATE/config.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
# Kept enabled so anything that doesn't fit the four templates still has a way
|
||||
# in (the `ToDo` label exists for exactly those).
|
||||
blank_issues_enabled: true
|
||||
|
||||
contact_links:
|
||||
- name: Translate Calendula
|
||||
url: https://weblate.dev.jeanlucmakiola.de/engage/calendula/
|
||||
about: >-
|
||||
Translations are managed on Weblate, not here — it owns every values-*
|
||||
file, so a hand-edited translation gets overwritten on the next sync.
|
||||
No coding needed: pick or request a language and translate in the browser.
|
||||
|
||||
- name: Contributing guide
|
||||
url: https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
|
||||
about: >-
|
||||
Before opening a pull request: the issue-first workflow, which release
|
||||
branch to target, how to build (there's a submodule), and the
|
||||
architectural rules a change is reviewed against.
|
||||
@@ -5,6 +5,7 @@ title: "Crash: "
|
||||
labels:
|
||||
- bug
|
||||
- crash
|
||||
- priority/high
|
||||
---
|
||||
|
||||
<!--
|
||||
@@ -3,7 +3,7 @@ name: Feature request
|
||||
about: Suggest an idea or improvement
|
||||
title: ""
|
||||
labels:
|
||||
- enhancement
|
||||
- feat
|
||||
---
|
||||
|
||||
### What would you like Calendula to do?
|
||||
19
.forgejo/ISSUE_TEMPLATE/question.md
Normal file
19
.forgejo/ISSUE_TEMPLATE/question.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Question
|
||||
about: Ask how something works or get help using Calendula
|
||||
title: ""
|
||||
labels:
|
||||
- question
|
||||
---
|
||||
|
||||
### Your question
|
||||
|
||||
|
||||
### What you've tried
|
||||
<!-- so far, if anything -->
|
||||
|
||||
|
||||
### Context
|
||||
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||
- Android version:
|
||||
- Device:
|
||||
41
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal file
41
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,41 @@
|
||||
<!--
|
||||
Thanks for contributing to Calendula!
|
||||
|
||||
Please skim CONTRIBUTING.md if you haven't:
|
||||
https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
|
||||
|
||||
Two things it's easy to get wrong:
|
||||
• Features need a discussed issue first — an undiscussed feature PR may be
|
||||
closed unmerged even when the code is good.
|
||||
• Target the release branch for your issue's milestone (milestone 2.18.0 →
|
||||
release/v2.18.0), not main. If you targeted main, just say so below and it
|
||||
will be retargeted.
|
||||
-->
|
||||
|
||||
### What this changes
|
||||
|
||||
|
||||
### Why
|
||||
|
||||
<!-- Closes #123 — link the issue this implements or fixes. -->
|
||||
|
||||
|
||||
### How it was tested
|
||||
|
||||
<!--
|
||||
Which of these ran green, and anything you exercised by hand. On-device notes
|
||||
are especially useful for UI changes.
|
||||
|
||||
./gradlew lint test assembleDebug
|
||||
python3 scripts/check_translations.py
|
||||
-->
|
||||
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] There's an issue for this, and (for a feature) it got a go-ahead
|
||||
- [ ] Targeting the release branch for that issue's milestone — or `main`, noted above
|
||||
- [ ] `./gradlew lint test assembleDebug` passes locally
|
||||
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
|
||||
- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible
|
||||
- [ ] No planning or design documents committed
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
with:
|
||||
# Full history so the base..HEAD diff below has a merge-base.
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
# Cheap, always-on guard: the release build must stay reproducible for the
|
||||
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
|
||||
@@ -36,14 +37,29 @@ 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`.
|
||||
# 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.
|
||||
@@ -57,11 +73,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
|
||||
@@ -71,9 +91,14 @@ jobs:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
|
||||
# data.forgejo.org, Forgejo's own action mirror — actions/checkout,
|
||||
# setup-java and cache all exist there, but android-actions/setup-android
|
||||
# does not, and the job dies with "repository not found". Gitea's instance
|
||||
# defaults to GitHub, which is why this never surfaced before the split.
|
||||
- name: Setup Android SDK
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
uses: android-actions/setup-android@v3
|
||||
uses: https://github.com/android-actions/setup-android@v3
|
||||
with:
|
||||
# Default ("tools platform-tools") drags in the Android Emulator
|
||||
# (~300 MB) which the build never uses.
|
||||
@@ -3,13 +3,15 @@ name: Translations
|
||||
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
||||
# only touch values-*/strings.xml) get quick feedback without the full Android
|
||||
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
||||
#
|
||||
# Runs on every PR (no path filter) so the required "Translations / check"
|
||||
# status is always reported — like the `ci` job. A path-filtered workflow is
|
||||
# skipped on unrelated PRs and never posts its status, which leaves that
|
||||
# required check pending forever and blocks the merge of any code-only PR into a
|
||||
# release/* branch. The check itself is cheap and simply passes when the
|
||||
# committed translations are consistent, so always running it costs nothing.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'app/src/main/res/values*/strings.xml'
|
||||
- 'app/src/main/res/xml/locales_config.xml'
|
||||
- 'scripts/check_translations.py'
|
||||
- '.gitea/workflows/translations.yaml'
|
||||
|
||||
concurrency:
|
||||
group: translations-${{ github.ref }}
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Something doesn't work the way it should
|
||||
title: ""
|
||||
labels:
|
||||
- bug
|
||||
---
|
||||
|
||||
### What happened
|
||||
|
||||
|
||||
### What you expected
|
||||
|
||||
|
||||
### Steps to reproduce
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Environment
|
||||
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||
- Android version:
|
||||
- Device:
|
||||
@@ -1,11 +1,19 @@
|
||||
name: Release — F-Droid repo + Gitea release
|
||||
name: Release — F-Droid repo + Gitea/Codeberg release + Play
|
||||
|
||||
# A release is cut by merging a release branch into main with a bumped
|
||||
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
|
||||
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
|
||||
# it to the F-Droid repo, and only then creates the vX.Y.Z tag + Gitea release
|
||||
# itself — the tag is an output of the pipeline, not its trigger. Ordinary
|
||||
# merges (no version bump) fall through `detect` and do nothing.
|
||||
# it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and mirrors
|
||||
# that release to Codeberg with the signed APK + a SHA-256 checksum as a
|
||||
# direct-download channel — the tag is an output of the pipeline, not its
|
||||
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
||||
# nothing.
|
||||
#
|
||||
# A 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,
|
||||
@@ -25,6 +33,14 @@ jobs:
|
||||
# whether this push actually cuts a new release (no tag for it yet). Keeps the
|
||||
# heavy job from running on every merge to main.
|
||||
detect:
|
||||
# Gitea only. The workflow directory split already keeps this file invisible
|
||||
# to Codeberg — Forgejo's lookup is first-match-wins, and .forgejo/workflows
|
||||
# exists — but that only holds while .forgejo/ is non-empty. Move the last
|
||||
# file out of it and Codeberg would fall back to .gitea/workflows and start
|
||||
# running the release pipeline on the contributor-facing runner, with no
|
||||
# secrets. repository_owner differs between the two forges regardless of
|
||||
# URL, proxy or instance rename, so this closes it permanently.
|
||||
if: github.repository_owner == 'makiolaj'
|
||||
runs-on: docker
|
||||
outputs:
|
||||
is_release: ${{ steps.v.outputs.is_release }}
|
||||
@@ -33,12 +49,22 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Resolve version and whether it is a new release
|
||||
id: v
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
# Tags are read from Codeberg, which is canonical — deliberately NOT
|
||||
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
|
||||
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
|
||||
# source does not have. A tag minted here on Gitea is therefore wiped
|
||||
# by the next sync (Codeberg does not have it yet) and only reappears
|
||||
# once the tag push at the end of this workflow propagates back.
|
||||
# Asking Gitea inside that window would report "no tag" for a release
|
||||
# that already shipped, and cut it a second time.
|
||||
# Public repo, so this read needs no token.
|
||||
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
|
||||
run: |
|
||||
set -e
|
||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||
@@ -56,15 +82,28 @@ jobs:
|
||||
fi
|
||||
# A tag for this version already existing means the release shipped on
|
||||
# an earlier push; do nothing. Absent => this merge cuts the release.
|
||||
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "Tag v$VERSION already exists — nothing to release."
|
||||
#
|
||||
# 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"
|
||||
else
|
||||
echo "No tag for v$VERSION yet — cutting the release."
|
||||
;;
|
||||
404)
|
||||
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
|
||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
|
||||
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
||||
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
||||
@@ -82,6 +121,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
@@ -223,9 +264,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
|
||||
@@ -346,3 +388,268 @@ jobs:
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@/tmp/$ASSET" \
|
||||
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
|
||||
|
||||
# Mirror the release to the Codeberg mirror as a direct-download channel
|
||||
# for users who don't want F-Droid. Gitea already push-mirrors branches +
|
||||
# tags to Codeberg, but releases aren't git objects so they don't sync —
|
||||
# we create the release there over the API and attach the signed APK plus
|
||||
# a SHA-256 checksum. The APK is identical to the F-Droid one (same app
|
||||
# key), so this adds no trust surface. Best-effort: a Codeberg outage
|
||||
# (it 504s under load) must never fail an already-published F-Droid
|
||||
# release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
|
||||
- name: Publish release to Codeberg
|
||||
if: env.IS_RELEASE == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
|
||||
API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
if [ -z "${TOKEN:-}" ]; then
|
||||
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
|
||||
exit 0
|
||||
fi
|
||||
TAG="v$VERSION"
|
||||
APK="app/build/outputs/apk/release/app-release.apk"
|
||||
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
|
||||
ASSET_APK="calendula_v${VERSION}.apk"
|
||||
ASSET_SUM="${ASSET_APK}.sha256"
|
||||
cp "$APK" "/tmp/$ASSET_APK"
|
||||
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
|
||||
|
||||
# Release notes: reuse the section extracted for the Gitea release,
|
||||
# fall back to the CHANGELOG entry if that step's file is gone.
|
||||
if [ ! -s release-notes.md ]; then
|
||||
awk -v ver="$VERSION" '
|
||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||
/^## \[/ { flag = 0 }
|
||||
flag' CHANGELOG.md > release-notes.md
|
||||
sed -i -e '/./,$!d' release-notes.md
|
||||
fi
|
||||
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
|
||||
# The pipeline creates the tag via the Gitea API, which the push mirror
|
||||
# (sync_on_commit only fires on real git pushes) doesn't propagate
|
||||
# promptly — so a release POST that carries a target_commitish can
|
||||
# outrun the mirror and 500 on a commit/tag Codeberg hasn't received.
|
||||
# Push the tag straight to Codeberg so it's guaranteed present, then
|
||||
# attach the release to that existing tag with NO target_commitish
|
||||
# (which is what triggered the 500).
|
||||
git tag -f "$TAG" "$SHA"
|
||||
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/calendula.git" \
|
||||
"refs/tags/$TAG"
|
||||
python3 - "$TAG" <<'PY' > cb-payload.json
|
||||
import json, sys
|
||||
print(json.dumps({
|
||||
"tag_name": sys.argv[1],
|
||||
"name": sys.argv[1],
|
||||
"body": open("release-notes.md").read(),
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}))
|
||||
PY
|
||||
# Create (or update) the release. Codeberg 500s on a POST/GET against a
|
||||
# tag it has only just received — the release request outruns the
|
||||
# indexing of the ref we pushed a moment ago — so a single attempt kept
|
||||
# failing and skipping the mirror even though the very same call
|
||||
# succeeds seconds later. Retry with backoff, and PATCH in place if a
|
||||
# release already exists (re-run safe). A 5xx body still exits curl 0,
|
||||
# so the loop, not `set -e`, controls the flow.
|
||||
ID=""
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
|
||||
if [ -n "$EXIST" ]; then
|
||||
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @cb-payload.json "$API/releases/$EXIST"
|
||||
ID="$EXIST"; break
|
||||
fi
|
||||
CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @cb-payload.json "$API/releases")
|
||||
echo "release POST attempt $attempt HTTP $CODE"
|
||||
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
|
||||
[ -n "$ID" ] && break
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
|
||||
|
||||
# Attach APK + checksum, replacing any prior asset of the same name.
|
||||
for A in "$ASSET_APK" "$ASSET_SUM"; do
|
||||
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
||||
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
|
||||
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@/tmp/$A" \
|
||||
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
|
||||
done
|
||||
echo "Published $TAG to Codeberg."
|
||||
|
||||
# Play takes an App Bundle, not the APK, so it is a second artifact from
|
||||
# the same source and the same signing config — not a repackage of the
|
||||
# APK. The release key signs it, but Play only ever treats that key as the
|
||||
# *upload* key: Play App Signing re-signs with Google's own key before
|
||||
# delivery. A Play install and an F-Droid install therefore carry
|
||||
# different signatures and cannot update each other. That divergence is a
|
||||
# deliberate, documented choice (docs/RELEASING.md), not an accident.
|
||||
#
|
||||
# Built LAST and `continue-on-error`, both deliberately: everything above
|
||||
# has already shipped by this point, and nothing Play-related may put that
|
||||
# at risk. Sitting mid-job without continue-on-error, this block took the
|
||||
# whole 2.17.0 release down with it — no F-Droid publish, no tag, no
|
||||
# Codeberg mirror — over an artifact upload. A failure here now costs the
|
||||
# Play upload and nothing else.
|
||||
#
|
||||
# Nothing here touches the F-Droid path: the AAB is never copied into the
|
||||
# repo, never attached to a release, and its build cannot change the APK
|
||||
# published above.
|
||||
#
|
||||
# AGP embeds the R8 mapping in the bundle's BUNDLE-METADATA, so Play gets
|
||||
# deobfuscated stacktraces without a separate mapping upload.
|
||||
- name: Build release AAB
|
||||
if: env.IS_RELEASE == 'true'
|
||||
continue-on-error: true
|
||||
run: ./gradlew bundleRelease
|
||||
|
||||
# NOT actions/upload-artifact@v4: it runs @actions/artifact v2, which
|
||||
# refuses to start whenever GITHUB_SERVER_URL is not github.com — it reads
|
||||
# any other forge as an unsupported GHES instance and fails before it ever
|
||||
# talks to the server (go-gitea/gitea#36024). Gitea 1.25 serves the v4
|
||||
# artifact API fine; only the client-side check is wrong. This fork is that
|
||||
# client with the check removed. Pinned to a commit, not the v4 branch: a
|
||||
# third-party action in the signing pipeline must not change under us.
|
||||
- name: Hand the AAB to the Play job
|
||||
if: env.IS_RELEASE == 'true'
|
||||
continue-on-error: true
|
||||
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
|
||||
with:
|
||||
name: release-aab-${{ needs.detect.outputs.version }}
|
||||
path: app/build/outputs/bundle/release/app-release.aab
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
# Google Play channel.
|
||||
#
|
||||
# A separate job, on purpose, running only AFTER the F-Droid publish and both
|
||||
# forge releases have completed. Play is the one channel that can reject a
|
||||
# perfectly good build for reasons outside the pipeline (listing rules, policy
|
||||
# review, API outage, a track that needs manual promotion). Isolating it means
|
||||
# such a rejection surfaces as one red job next to a release that already
|
||||
# shipped everywhere else, instead of failing the workflow that publishes it.
|
||||
#
|
||||
# Not a `container:` job even though a fastlane image exists: act_runner does
|
||||
# not provide node inside custom job containers, so JavaScript actions
|
||||
# (checkout, download-artifact) can't run there. The Renovate job gets away
|
||||
# with a container because its only step is a shell command. Ruby is installed
|
||||
# the same way sshpass, jq and fdroidserver are in the job above.
|
||||
play:
|
||||
needs: [detect, release]
|
||||
# workflow_dispatch is the F-Droid re-sign recovery path — it must never
|
||||
# touch Play, so gate on a real release only.
|
||||
if: needs.detect.outputs.is_release == 'true'
|
||||
runs-on: docker
|
||||
env:
|
||||
VERSION: ${{ needs.detect.outputs.version }}
|
||||
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
|
||||
# Where the bundle lands. `internal` by default so a release reaches
|
||||
# testers rather than the public, and promotion to production stays a
|
||||
# deliberate human action in the Play Console — the same posture as
|
||||
# holding UI releases for on-device review. Override with the PLAY_TRACK
|
||||
# repo variable once the flow is trusted.
|
||||
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'internal' }}
|
||||
PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }}
|
||||
# Set PLAY_DRY_RUN=true to validate the edit against the API and discard
|
||||
# it instead of committing — used to rehearse the first upload.
|
||||
PLAY_DRY_RUN: ${{ vars.PLAY_DRY_RUN || 'false' }}
|
||||
BUNDLE_PATH: vendor/bundle
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Skip cleanly (not fatally) when Play isn't configured yet, so the rest
|
||||
# of the release pipeline keeps working during setup — same contract as
|
||||
# the Codeberg mirror step.
|
||||
- name: Write the Play service-account key
|
||||
id: key
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${PLAY_SERVICE_ACCOUNT_JSON:-}" ]; then
|
||||
echo "PLAY_SERVICE_ACCOUNT_JSON not set — skipping the Play upload."
|
||||
echo "configured=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
|
||||
# Fail here, with a clear message, rather than inside fastlane: a
|
||||
# mangled multi-line secret is the likeliest setup mistake.
|
||||
python3 -c "import json,sys; d=json.load(open('play-service-account.json')); sys.exit(0 if d.get('type')=='service_account' else 1)" \
|
||||
|| { echo "PLAY_SERVICE_ACCOUNT_JSON is not a valid service-account JSON." >&2; exit 1; }
|
||||
echo "configured=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Same GHES-detection problem as the upload side, same fix — see the
|
||||
# handoff step in the release job.
|
||||
- name: Download the AAB
|
||||
if: steps.key.outputs.configured == 'true'
|
||||
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 # v4
|
||||
with:
|
||||
name: release-aab-${{ needs.detect.outputs.version }}
|
||||
path: dist
|
||||
|
||||
- name: Install Ruby
|
||||
if: steps.key.outputs.configured == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SUDO=""
|
||||
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
|
||||
$SUDO apt-get update
|
||||
# ruby-dev + build-essential: several of fastlane's dependencies build
|
||||
# native extensions.
|
||||
$SUDO apt-get install -y ruby-full ruby-dev build-essential
|
||||
ruby -v
|
||||
|
||||
# Only the first release pays the full gem build; afterwards this restores.
|
||||
- name: Cache bundled gems
|
||||
if: steps.key.outputs.configured == 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: vendor/bundle
|
||||
key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gems-
|
||||
|
||||
- name: Install fastlane
|
||||
if: steps.key.outputs.configured == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gem install bundler --no-document
|
||||
bundle config set --local path vendor/bundle
|
||||
bundle install --jobs 4
|
||||
bundle exec fastlane --version
|
||||
|
||||
- name: Upload to Play
|
||||
if: steps.key.outputs.configured == 'true'
|
||||
env:
|
||||
SUPPLY_JSON_KEY: play-service-account.json
|
||||
# supply is chatty on a TTY-less runner otherwise.
|
||||
FASTLANE_SKIP_UPDATE_CHECK: '1'
|
||||
FASTLANE_HIDE_CHANGELOG: '1'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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
|
||||
|
||||
@@ -29,14 +29,30 @@ jobs:
|
||||
- name: Run Renovate
|
||||
run: renovate
|
||||
env:
|
||||
# Self-hosted Gitea, not github.com.
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_ENDPOINT: https://gitea.jeanlucmakiola.de/api/v1
|
||||
# Bot-account token (Gitea secret). Needs repo read/write + PR scope.
|
||||
# Renovate targets Codeberg (canonical) while still RUNNING on the
|
||||
# Gitea runner. Moving the job to Codeberg would put a repo-write
|
||||
# token on the contributor-facing runner, which is exactly what the
|
||||
# .forgejo/ vs .gitea/ split exists to prevent — so the token stays
|
||||
# where the other secrets live and only the API calls cross over.
|
||||
#
|
||||
# Platform is `forgejo`, not `gitea`: Codeberg runs Forgejo, and the
|
||||
# pinned image ships a distinct forgejo platform module.
|
||||
RENOVATE_PLATFORM: forgejo
|
||||
RENOVATE_ENDPOINT: https://codeberg.org/api/v1
|
||||
# Codeberg bot-account token (Gitea secret). Needs repo read/write +
|
||||
# PR scope on jlmakiola/calendula.
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
# Scope to this repo only — no org-wide autodiscovery.
|
||||
RENOVATE_AUTODISCOVER: 'false'
|
||||
RENOVATE_REPOSITORIES: '["makiolaj/calendula"]'
|
||||
# Commits/PRs authored as the bot, not a real maintainer.
|
||||
RENOVATE_REPOSITORIES: '["jlmakiola/calendula"]'
|
||||
# Commits/PRs authored as the bot, not a real maintainer. This address
|
||||
# must be a verified email on the Codeberg bot account, otherwise the
|
||||
# commits show up unattributed there.
|
||||
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@jeanlucmakiola.de>'
|
||||
# Read-only github.com PAT (no scopes needed). Unaffected by the forge
|
||||
# move — nearly every dependency is *released* on GitHub, and without
|
||||
# this,
|
||||
# changelog/release-note lookups hit the 60/h anonymous rate limit
|
||||
# and PRs arrive with an empty "Release Notes" section.
|
||||
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.GITHUB_COM_TOKEN }}
|
||||
LOG_LEVEL: info
|
||||
|
||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -50,8 +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
|
||||
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "floret-kit"]
|
||||
path = floret-kit
|
||||
url = https://codeberg.org/jlmakiola/floret-kit.git
|
||||
@@ -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,467 +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)*; optional quick-settings tile still open
|
||||
|
||||
**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** *(in progress → v2.7)* — one
|
||||
hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`),
|
||||
four surfaces: single-event share + whole-calendar backup (export),
|
||||
open-`.ics`→form + whole-calendar restore (import). Closes the
|
||||
device-local-calendar data-loss gap (#10/#11 merged here). Built as **two
|
||||
sequential branches in one release**: `feat/ics-export` (write side +
|
||||
UID-on-create precursor) then `feat/ics-import` (parser, restore, dedup).
|
||||
Import is liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` /
|
||||
`RECURRENCE-ID` it can't model. Timezone rule: all-day `VALUE=DATE`,
|
||||
non-recurring timed UTC `Z`, recurring timed `TZID`-labelled from the stored
|
||||
`EVENT_TIMEZONE` (no `VTIMEZONE` blocks; resolved against the OS tz DB on
|
||||
import). Plan: `docs/superpowers/plans/2026-06-18-05-ics-export.md`.
|
||||
11. **Snooze / dismiss notification actions** *(next, after v2.7)* — follows the
|
||||
`.ics` work; inherits v2.6's deferred exact-alarm/WorkManager decision (snooze
|
||||
must re-fire an alarm).
|
||||
12. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog)
|
||||
|
||||
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
|
||||
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
|
||||
- Locations & People — contact address picker (no-permission, one-shot) is the safe entry; 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,
|
||||
full-text search, ICS file import. Pulled in opportunistically, not sequenced.
|
||||
|
||||
Debatable calls worth a second look: whether **local-calendar backup (#10)**
|
||||
should lead Tier 4 outright (it's a silent data-loss risk, not a feature);
|
||||
whether drag-drop (#12) jumps ahead given its daily-driver impact.
|
||||
|
||||
## 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 — standard in every calendar, cheap,
|
||||
currently absent. Daily-driver polish.
|
||||
- Week numbers in the **month** grid — week view already shows the badge
|
||||
(`WeekNumberBadge`, `WeekScreen.kt`); extend to month for ISO/European users.
|
||||
- Pinch-to-zoom time scale in day/week
|
||||
- Tablet / foldable layouts *(was v3.0)*
|
||||
- Full-text search *(was v3.0)* — promote out of "fill-in": for a daily driver
|
||||
with real event history, finding an event is core completeness, not optional.
|
||||
|
||||
## Event editing & creation
|
||||
|
||||
- Drag & drop rescheduling in day/week (recurring drops reuse the scope
|
||||
dialog) — big-ticket, own slice
|
||||
- Duplicate event (detail action → prefilled create form)
|
||||
- **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form)
|
||||
*(next)* — chosen to follow the in-progress tap-to-create + calendar
|
||||
management work: reuses the color-picker component and palette plumbing
|
||||
being built for local calendar management, and finishes the create/edit
|
||||
theme. `EVENT_COLOR` / `EVENT_COLOR_KEY` from the calendar's color list
|
||||
(`Colors` table, `TYPE_EVENT`); falls back to the calendar color when unset.
|
||||
|
||||
## 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)* — device-only
|
||||
(`ACCOUNT_TYPE_LOCAL`) calendars are first-class in Calendula but have **no
|
||||
sync and therefore no backup**: a lost/wiped phone destroys them permanently.
|
||||
Whole-calendar `.ics` (VCALENDAR) export to a user-chosen file (SAF), plus
|
||||
restore-on-import that recreates events into a chosen local calendar. Reuses
|
||||
the .ics serializer from the single-event share work; the restore path reuses
|
||||
the import parser. A data-integrity obligation, not a feature.
|
||||
|
||||
## Reminders — defaults & delivery reliability *(implemented 2026-06-17, `feat/default-reminders` — pending on-device review)*
|
||||
|
||||
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).
|
||||
|
||||
### Open decisions (resolve before building)
|
||||
|
||||
1. Self-schedule via `AlarmManager` vs trust the provider broadcast
|
||||
(reliability vs simplicity + battery cost).
|
||||
2. All-day reminder representation (minutes-before vs absolute time-of-day).
|
||||
3. Where per-calendar overrides live in the UI (rows on the Calendars screen vs
|
||||
a list inside the Notifications sub-page).
|
||||
|
||||
### Later (round two)
|
||||
|
||||
- Snooze + dismiss actions on the notification (snooze needs an
|
||||
exact-alarm / WorkManager decision) — Tier 4 #13.
|
||||
|
||||
## Sharing & interop
|
||||
|
||||
- Share event as .ics + open/receive .ics into a prefilled create form
|
||||
(front-runs the import below)
|
||||
- ICS file import (drag-and-drop) *(was v3.0, optional)*
|
||||
|
||||
## Platform & launchers
|
||||
|
||||
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
|
||||
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0** —
|
||||
optional quick-settings tile still open
|
||||
|
||||
## 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` on postal addresses) — one-shot, needs no READ_CONTACTS,
|
||||
fits the privacy story. Same mechanism later for picking emails.
|
||||
- **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 / invites from contacts** — own milestone; writing
|
||||
`Attendees` rows touches sync-adapter invitation behavior (Google vs
|
||||
DAVx5 differ).
|
||||
|
||||
## 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)
|
||||
@@ -1,130 +0,0 @@
|
||||
# Calendula — Current State
|
||||
|
||||
*Last updated: 2026-06-17*
|
||||
|
||||
## Status
|
||||
|
||||
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11;
|
||||
v2.1.0 (month event grid, drawer view tabs, cursor fix) shipped 2026-06-15.
|
||||
**Phase:** post-2.1 backlog work. v2.2.0 (tap-to-create in day/week + local
|
||||
calendar management) and v2.3.0 (Material 3 grouped-list redesign of Settings,
|
||||
the calendar manager and the navigation drawer) both shipped 2026-06-16;
|
||||
v2.4.0 (per-event colors) and v2.5.0 (jump-to-date, Agenda view, home-screen
|
||||
agenda + month widgets, and a "New event" launcher shortcut) shipped
|
||||
2026-06-17. The backlog is now organised by theme in `ROADMAP.md`.
|
||||
|
||||
## 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.
|
||||
|
||||
## Next
|
||||
|
||||
1. Monitor the F-Droid build/publish for the v2.4.0 tag
|
||||
2. Decide the "Locations & People" and "remote calendar create/edit"
|
||||
go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md`
|
||||
3. **Duplicate event** and **jump-to-date** are the cheap follow-ups; then
|
||||
agenda view (strategic, backs a future widget). Full ranked sequence in
|
||||
`ROADMAP.md` → "Near-term sequence".
|
||||
789
CHANGELOG.md
789
CHANGELOG.md
@@ -7,6 +7,729 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
- Choose how the month view is laid out. A new **Month view style** setting
|
||||
(Settings → Views) offers three ways to read a month, each shown with a
|
||||
preview of the layout it produces:
|
||||
- **Pages** — what you have today: one month at a time, swiped sideways.
|
||||
- **Continuous** — scroll up and down through the weeks without a break
|
||||
between months. Because the weeks run on unbroken, no month is cut off and
|
||||
no day appears twice, where paging repeats a boundary week at the end of one
|
||||
month and the start of the next. The 1st of each month names itself so you
|
||||
always know where you are, and the title bar keeps up as you scroll ([#38]).
|
||||
- **Split** — a compact grid showing coloured dots for the days that have
|
||||
something on them, with the day you tap listed in full underneath. Tap the
|
||||
date above the list to open the whole day ([#53]).
|
||||
|
||||
The Agenda view is untouched by this and stays available in all three styles —
|
||||
the split layout lists a single day, while Agenda remains a rolling multi-day
|
||||
window with its own range settings.
|
||||
- Give an event its own time zone. A new **Time zone** field (under "more
|
||||
fields" in the event form) pins an event to a specific zone, so a call set for
|
||||
8:00 AM in New York stays 8:00 AM in New York wherever you open it — and keeps
|
||||
tracking that zone across daylight-saving changes instead of drifting an hour.
|
||||
The form edits the event in its own zone and shows the local equivalent under
|
||||
the times ("2:00 PM – 3:00 PM your time"); the event's details keep your local
|
||||
time first and note the original beneath it, so both are always clear. Pick a
|
||||
zone from a full-screen picker with your device zone and recent choices on top,
|
||||
searching by city ("new york"), IANA id ("europe/berlin"), or abbreviation
|
||||
("CEST") to gather every matching zone at once. All-day events stay
|
||||
date-anchored and carry no zone, as before ([#31]).
|
||||
- Put the "jump to today" button in the toolbar. A new **Today button in
|
||||
toolbar** setting (Settings → Appearance, off by default) swaps the floating
|
||||
button that fades into the corner while you're away from today for a permanent
|
||||
today icon in the top bar — always there, on today or not, matching the
|
||||
familiar calendar-app pattern. Leave it off to keep the floating button as
|
||||
before ([#60]).
|
||||
- Choose what Calendula calls itself on your home screen. A new **App name**
|
||||
setting (Settings → Appearance) switches the launcher label between
|
||||
**Calendula** and **Calendar**, for launchers that can't rename apps
|
||||
themselves. Pick from a full-screen chooser that previews both names as
|
||||
launcher marks; the change applies at once ([#44]).
|
||||
- Calendula now speaks **Arabic**, laid out right-to-left, and its French and
|
||||
Italian translations have been brought up to date — thanks to the community
|
||||
translators on Weblate. Pick a language under Settings → Language (or leave it
|
||||
on the system default).
|
||||
|
||||
### Changed
|
||||
- Dates in the Month, Week and Day title bars now follow your language and
|
||||
region instead of one hardcoded layout. Every date was rendered in a fixed
|
||||
German-style order with a trailing dot on the day number, whatever your
|
||||
settings: US English showed "Fri, 17. Jul 2026" where it should read
|
||||
"Fri, Jul 17". The Agenda view already formatted correctly, so the two
|
||||
disagreed about the same date. All four views now share one formatter, and the
|
||||
day/month order, the separators and the ordinal all come from your locale —
|
||||
so English-in-Germany reads "Fri, 17 Jul" and English-in-the-US "Fri, Jul 17",
|
||||
each correct for where you are ([#60]).
|
||||
- The title bar drops the year while you're in the current one — "July" rather
|
||||
than "July 2026". The year reappears the moment you page out of the current
|
||||
year, which is when it tells you something you didn't already know.
|
||||
- The Week view's title now names the month instead of spelling out the day range.
|
||||
"24. Jun – 31. Jun" restated the day numbers already printed in the column
|
||||
headers right below it, in the widest string in the bar. A week that straddles
|
||||
two months keeps the outgoing month until it is fully gone ([#60]).
|
||||
- The custom recurrence picker has been redesigned and tightened up. As you
|
||||
build a rule — "every 2 weeks on Mon & Wed, until a date" — the live summary
|
||||
now describes exactly what will be saved rather than a near-copy that could
|
||||
drift from it, the amount fields accept being left blank (reading as their
|
||||
shown default instead of greying out OK), and the read-out no longer jumps
|
||||
around as you tap weekdays ([#42]).
|
||||
|
||||
### Fixed
|
||||
- An all-day event no longer shows up again the day after it happened. In time
|
||||
zones east of UTC, an all-day event — a birthday, say — set for one day also
|
||||
appeared under the *next* day's heading in the Agenda (and the agenda widget),
|
||||
because all-day events are anchored to UTC midnight and the following day's
|
||||
window reached back across that boundary and pulled the event forward onto
|
||||
"today". Each all-day event now lists only on the day it actually falls on
|
||||
([#65]).
|
||||
- A multi-day event now shows under every day it spans in the Agenda, not just
|
||||
its first day, so a trip or a multi-day booking appears on each day it covers.
|
||||
- The "Upcoming" agenda widget now scales its text and rows to the size you give
|
||||
it. Previously it was laid out once for the smallest size and simply stretched
|
||||
when enlarged, so the text stayed small no matter how big you made the widget.
|
||||
Now a bigger widget gets bigger, more readable type and roomier rows, while the
|
||||
default size looks exactly as before — no new setting; it follows the size you
|
||||
already chose ([#51]).
|
||||
- Calendula now appears under other apps' "Add to calendar" / "Save to calendar"
|
||||
actions. Some apps (e.g. DB Navigator) fire the widely-used "insert event"
|
||||
intent with the singular `vnd.android.cursor.item/event` type, which Calendula
|
||||
didn't advertise — so it was left out of the chooser, and if it was your only
|
||||
calendar app the save silently did nothing. It now accepts that form, plus the
|
||||
`INSERT_OR_EDIT` action, and opens the new event prefilled for review ([#74]).
|
||||
- Opening a `.ics`/`.vcs` file now works even when another app hands it over
|
||||
mislabelled as a generic download (`application/octet-stream`), as some mail
|
||||
clients, browsers and file managers do — Calendula recognises it by its file
|
||||
extension instead of relying on the declared type ([#74]).
|
||||
- A recurrence end date no longer lands a day late. West of UTC, setting a rule
|
||||
to end "until" a given day could save and show the day after the one picked;
|
||||
the end date now reads back as chosen ([#42]).
|
||||
- The status- and navigation-bar icons stay legible over full-screen pickers in
|
||||
dark theme. They could render dark-on-dark — a near-invisible black clock
|
||||
against the dark picker — instead of switching to light ([#70]).
|
||||
|
||||
## [2.15.0] — 2026-07-15
|
||||
|
||||
### Added
|
||||
- Show raw calendar colours. Calendula normally softens each calendar and event
|
||||
colour toward a theme-fitting pastel so harsh sync colours read well on both
|
||||
light and dark; a new **Soften calendar colours** setting (Settings → Design,
|
||||
on by default) lets you turn that off and paint the exact colours your calendar
|
||||
source publishes — matching DAVx5/CalDAV and other calendar apps. Thanks to
|
||||
@leonp5 for the report ([#36]).
|
||||
- Readable titles on dark event colours. An event bar's title now shows in white
|
||||
on a dark colour and near-black on a light one, chosen automatically from the
|
||||
colour's brightness, so a deep blue or purple event is legible at a glance in
|
||||
the busy Week and Month views instead of dark-on-dark. This applies whether or
|
||||
not colours are softened. Thanks to @ptab for the suggestion ([#21]).
|
||||
- A custom snooze duration. The **Snooze duration** setting (Settings →
|
||||
Notifications) gains a **Custom…** option next to the minute presets: pick any
|
||||
amount and switch between minutes and hours, so a snoozed reminder comes back
|
||||
after exactly the delay you want instead of only a preset one ([#40]).
|
||||
- Move an event to another calendar. When editing an existing event, the
|
||||
calendar row is now tappable — pick a different calendar and saving moves the
|
||||
event across, instead of having to delete it and recreate it elsewhere.
|
||||
Recurring series move as a whole, keeping their individually-edited and
|
||||
cancelled occurrences, and any reminders and guests come along too. A calendar
|
||||
can't simply be reassigned underneath an event, so Calendula recreates it on
|
||||
the target and removes the original — the same approach other calendar apps
|
||||
take. Thanks to @prismplex for the suggestion ([#39]).
|
||||
- Open an event straight into the edit form from another app. Calendula already
|
||||
answered the "new event" and "open this event" hand-offs from other apps and
|
||||
widgets; it now also answers the "edit this event" one, so an assistant, task
|
||||
app, or widget can send an existing event to Calendula and land on its edit
|
||||
screen rather than the read-only details. A hand-off with no event attached
|
||||
opens the same prefilled create form as "new event". Calendula also recognises
|
||||
a couple more file labels the same calendar data arrives under (`.vcs`
|
||||
vCalendar files and the `application/ics` type), so opening or sharing those
|
||||
into Calendula works too.
|
||||
- Keep today at the top of the agenda. A new **Always show today** setting
|
||||
(Settings → Agenda, on by default) anchors today as the first entry in both the
|
||||
Agenda screen and its home-screen widget even once nothing is left today —
|
||||
under today's header a "No more events today" note appears — so the first
|
||||
events you see are clearly today's rather than a future day's. Turn it off to
|
||||
keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]).
|
||||
- Duplicate an event. The event details now carry a **Duplicate** action that
|
||||
opens the editor pre-filled with a copy of the event as a new, unsaved one, so
|
||||
a one-off like a shift or an appointment can be recreated by just changing the
|
||||
day and time instead of re-typing every field. The copy keeps the original's
|
||||
time, and its title, location, notes, colour, guests and reminders come along;
|
||||
it's saved as its own single event (any repeat is left off — add one in the
|
||||
editor if you want it). Duplicate works from read-only calendars too, dropping
|
||||
the copy into a writable one. Thanks to @internet-rando for the suggestion
|
||||
([#52]).
|
||||
- French and Polish, in early form. Calendula has started speaking French and
|
||||
Polish, both contributed as community translations through
|
||||
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||
They are partway there, so untranslated parts still show in English until they
|
||||
fill out — you can already pick either under Settings → Language or in Android's
|
||||
per-app language settings. Thanks to Thomas Tref (French) and Bazyli Cyran
|
||||
(Polish) for getting them started; help finishing them is very welcome.
|
||||
|
||||
### Fixed
|
||||
- Reminders for events on another day no longer read as if they were today. A
|
||||
reminder fired ahead of time — say, the day before — used to show only the
|
||||
event's time, making it look like it was happening now. The notification now
|
||||
says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
|
||||
week, or the date for anything further out. Thanks to @moonj for the report
|
||||
([#46]).
|
||||
- Agenda dates now read in your locale's format. The agenda's range bar and its
|
||||
day headers used a fixed day-month-year layout — and the range span even mixed
|
||||
two orders (e.g. "15 Jul – Aug 13, 2026") — instead of following your language's
|
||||
conventions. Dates across the agenda and its widget now use your locale's own
|
||||
field order, matching the rest of the app. The range bar also no longer repeats
|
||||
the range's name from the selector button beside it, showing just the dates.
|
||||
- Calendar gutters line up with the menu button. The Month view's week-number
|
||||
column, and the Week and Day views' hour labels, sat a few pixels left of the
|
||||
hamburger menu above them; they now line up with it. In Month view the day
|
||||
cells also sit squarely under their weekday letters.
|
||||
|
||||
## [2.14.1] — 2026-07-13
|
||||
|
||||
### Fixed
|
||||
- Deleting one occurrence of a repeating event no longer breaks the series.
|
||||
Choosing "This event" when deleting an occurrence of a recurring event could
|
||||
wipe out every *other* occurrence while leaving the one you deleted behind as
|
||||
a stale, still-tappable ghost — and deleting it again brought the series back.
|
||||
A single-occurrence delete now removes exactly that occurrence and leaves the
|
||||
rest of the series untouched, and the deleted occurrence disappears from the
|
||||
grid straight away. This holds on every kind of calendar, including the
|
||||
on-device ones Calendula keeps for contact birthdays and anniversaries, where
|
||||
the series is a yearly repeat. Thanks to @moonj for the report ([#47]).
|
||||
- Tapping an event in a third-party widget opens it in Calendula. v2.13.1 taught
|
||||
Calendula to answer the "new event" hand-off from other apps and widgets; now
|
||||
it also answers the "open this event" one, so tapping an existing event in a
|
||||
widget such as Todo Agenda offers Calendula and lands on that event's details.
|
||||
Thanks to @bushrang3r for the report ([#48]).
|
||||
- Events created from other apps get your default reminder. An event handed over
|
||||
by another app or widget — Google Maps' "add to calendar", the Todo Agenda
|
||||
widget's "+" — opened with no reminder at all, ignoring the default set in
|
||||
Settings. It now starts with your default reminder, the same as an event you
|
||||
create in Calendula. An event opened from an `.ics` file is treated differently,
|
||||
because the file has its own say: Calendula keeps whatever reminders it carries
|
||||
(including none at all) and asks you once whether to apply your default instead
|
||||
— it never quietly overrides the file. If you have no default set, it doesn't
|
||||
ask ([#49]).
|
||||
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
|
||||
app (such as DAVx5), the event colour picker showed every colour the account
|
||||
publishes — nearly 150 swatches in alphabetical order, many of them
|
||||
duplicates or near-identical shades. The picker now shows only visually
|
||||
distinct colours, arranged as a rainbow; near-duplicate shades and the
|
||||
washed-out neutrals are folded away so no two swatches look alike. Picked
|
||||
colours still sync exactly as before, and calendars with hand-picked
|
||||
palettes (like Google's) are unaffected. Thanks to @ptab for the report
|
||||
([#22]).
|
||||
|
||||
## [2.14.0] — 2026-07-06
|
||||
|
||||
### Added
|
||||
- Restore events from a backup file. The backup section of Settings can now read
|
||||
events back **in** from an `.ics` file, not just write one out: pick a file,
|
||||
choose which calendar to import into, and Calendula adds the events — skipping
|
||||
any that are already there and telling you how many it skipped. Export gained a
|
||||
per-calendar selector at the same time, so you can back up just the calendars
|
||||
you pick instead of everything at once ([#32]).
|
||||
- Week numbers in Month view. A new **Week numbers** setting (off by default)
|
||||
adds a slim gutter down the left of the Month grid showing the calendar-week
|
||||
number for each row, sized to match the day cells. Handy if you plan or refer
|
||||
to dates by week number ([#25]).
|
||||
- Tap a date header to open that day. In Week and Agenda view, tapping a date
|
||||
header now opens that date in Day view — the same drill-in that Month view and
|
||||
the agenda widget already offered, so every view behaves the same way. It makes
|
||||
jumping to a specific day quicker: switch to Week, swipe to the week you want,
|
||||
then tap the date to open it. Thanks to @ptab for the suggestion ([#37]).
|
||||
- An early Simplified Chinese translation. Calendula has started speaking
|
||||
Simplified Chinese, contributed as a community translation through
|
||||
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||
It is still an early effort, so many parts of the app show in English until it
|
||||
fills out — you can already pick it under Settings → Language or in Android's
|
||||
per-app language settings. Thanks to
|
||||
[zh-cn](https://weblate.dev.jeanlucmakiola.de/user/zh-cn/) for getting it
|
||||
started; help finishing it is very welcome.
|
||||
|
||||
### Changed
|
||||
- Long event titles wrap in the edit screen. When editing an event, a long title
|
||||
now wraps onto multiple lines instead of being clipped to a single line, so you
|
||||
can see and edit the whole thing ([#33]).
|
||||
|
||||
## [2.13.1] — 2026-07-06
|
||||
|
||||
### Added
|
||||
- Create events from other apps and widgets. Calendula now registers the
|
||||
standard "insert event" intent (`ACTION_INSERT` on the calendar events type),
|
||||
so other apps and home-screen widgets — such as the Todo Agenda widget — can
|
||||
hand off to Calendula to create a new event. It opens the new-event form
|
||||
prefilled with whatever they passed (title, start/end time, all-day, location,
|
||||
description, recurrence), and picks your last-used or first writable calendar.
|
||||
Thanks to @dschuermann for the suggestion ([#30]).
|
||||
|
||||
### Fixed
|
||||
- Some recurring events could not be opened. Events in a series that started
|
||||
before 1970 — for example yearly birthdays or anniversaries synced over CalDAV
|
||||
— showed "Something went wrong" instead of opening, because their stored start
|
||||
time is a negative value that was wrongly treated as invalid. They now open
|
||||
normally and appear in search again. A related case (an event whose stored end
|
||||
precedes its start) is now kept and openable instead of failing the same way.
|
||||
Thanks to @dschuermann for the report ([#34]).
|
||||
- The time picker now follows your 24-hour setting. With Calendula set to
|
||||
24-hour time, the clock dial for choosing an event's start and end time still
|
||||
showed AM/PM instead of a 24-hour dial; it now matches your setting (and the
|
||||
same fix applies to the all-day reminder time in Settings). Thanks to
|
||||
@abrossimow for the report ([#27]).
|
||||
|
||||
## [2.13.0] — 2026-07-03
|
||||
|
||||
### Added
|
||||
- Calendula now speaks Spanish and Italian. Both arrived as community
|
||||
translations through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/)
|
||||
— a **huge thank you** to
|
||||
[kikerw](https://weblate.dev.jeanlucmakiola.de/user/kikerw/) for the Spanish
|
||||
translation and to
|
||||
[corrent](https://weblate.dev.jeanlucmakiola.de/user/corrent/) for the Italian
|
||||
one! Pick your language under Settings → Language or in Android's per-app
|
||||
language settings. Strings added in this very release may still show in
|
||||
English until the translations catch up. Want Calendula in your language?
|
||||
Translating happens entirely in the browser — every contribution is welcome.
|
||||
- See your contacts' birthdays and anniversaries in your calendar. A new,
|
||||
**optional** feature (Settings → Contact special dates) mirrors your contacts'
|
||||
birthdays, anniversaries and other dates into local "Birthdays",
|
||||
"Anniversaries" and "Other dates" calendars that stay in sync as your contacts
|
||||
change. Each is a normal local calendar, so you set its colour, visibility and
|
||||
reminders the usual way — new birthdays even start with a reminder a week before
|
||||
*and* on the day. The title format is yours to customise (`{name}`, `{year}`).
|
||||
This is the first feature to use the contacts permission: it is requested only
|
||||
when you turn the feature on, everything stays on your device (Calendula has no
|
||||
internet access), and your contacts are only ever read, never changed. Thanks
|
||||
to @moonj for the suggestion ([#15]).
|
||||
- Set more than one default reminder per calendar. A calendar's default
|
||||
reminders — and the global defaults under Settings → Notifications — can now
|
||||
hold several lead times instead of just one, so new events can start with, say,
|
||||
a reminder a week before *and* one on the day. The reminder pickers are now
|
||||
multi-select; per-calendar overrides can still inherit the global default or
|
||||
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
|
||||
- Widget headers now open the app. Tapping the month/year title on the month
|
||||
widget opens the app on the month view, and tapping the "Upcoming" title on
|
||||
the agenda widget opens it on your default view — so there's a one-tap way
|
||||
back into the app that lands where you'd expect, instead of only through a day
|
||||
or event. On the month widget, tapping anywhere on a day — not just the small
|
||||
date number — now opens that day, and the "today" button snaps the grid back
|
||||
to the current month in place. Thanks to @rgz46vic and @ptab for the
|
||||
suggestions ([#18], [#20]).
|
||||
- Make Calendula's text your own. Settings → Appearance gains **Headings font**
|
||||
and **Body font** pickers: keep the system default, choose a bundled face
|
||||
(Atkinson Hyperlegible, Lora, JetBrains Mono — each previewed in its own
|
||||
face), or load any `.ttf`/`.otf` file from your device, independently per
|
||||
role. Font size and colour stay with Android's accessibility scaling and the
|
||||
app theme, as discussed on the issue. Thanks to @abrossimow for the
|
||||
suggestion ([#19]).
|
||||
- Choose what the view-switch button cycles through. Settings → Appearance now
|
||||
lets you pick which views the top-right quick-switch button rotates between
|
||||
and drag them into your preferred order; views you switch off stay reachable
|
||||
from the navigation menu, which can be reordered the same way. Thanks to
|
||||
@abrossimow for the suggestion ([#24]).
|
||||
|
||||
### Fixed
|
||||
- Month widget arrows and "today" button work again. On release builds the
|
||||
prev/next-month arrows and the jump-to-today control on the month widget did
|
||||
nothing when tapped — code shrinking had stripped the tap handlers behind
|
||||
them. They respond again. Thanks to @rgz46vic for the report ([#18]).
|
||||
- Disabled calendars no longer notify. Reminders for events in a calendar you
|
||||
have disabled (Settings → Calendars) are now suppressed instead of still
|
||||
popping up — matching how a disabled calendar's events already stay hidden
|
||||
everywhere else in the app ([#17]).
|
||||
- Editing a single occurrence of a recurring event works again. Choosing **Only
|
||||
this event** and saving a change to one event in a repeating series silently
|
||||
did nothing — the change was rejected and the edit form simply reappeared with
|
||||
nothing applied. The edited occurrence is now stored correctly, so the change
|
||||
lands on just that one event and leaves the rest of the series untouched
|
||||
([#16]).
|
||||
- Every calendar can be picked when creating an event. The event editor's
|
||||
calendar picker was a fixed-height dialog, so with many calendars anything
|
||||
past the first nine or so was simply unreachable. It is now a full-screen,
|
||||
scrollable list grouped by account, with each calendar's colour shown.
|
||||
Thanks to @dschuermann for the report ([#29]).
|
||||
|
||||
## [2.12.0] — 2026-06-28
|
||||
|
||||
### Added
|
||||
- See past events your way. Two new settings change how events that have already
|
||||
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
|
||||
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
|
||||
usual, dim them, or hide them from the list entirely. **Dim completed events**
|
||||
(Settings → Appearance) separately fades finished events in the month and week
|
||||
views. Both are off by default, an event only counts as finished once it has
|
||||
actually ended (events still in progress are never dimmed), and the lists
|
||||
update on their own as the day goes on. Thanks to @ptab for the suggestion
|
||||
([#12]).
|
||||
|
||||
### Changed
|
||||
- Readable titles on overlapping events. In the week and day views, events that
|
||||
overlap split a day into slim columns where the title used to be clipped to a
|
||||
character or two. The title now wraps across as many lines as the block can
|
||||
fit, and the time is hidden on those narrow blocks so the name can fill the
|
||||
space — so you can tell events apart without opening each one. Thanks to @ptab
|
||||
for the suggestion ([#13]).
|
||||
|
||||
## [2.11.2] — 2026-06-28
|
||||
|
||||
### Added
|
||||
- Jump straight to typing a title. When you start a new event, Calendula now puts
|
||||
the cursor in the title field and opens the keyboard right away, so you can type
|
||||
the name without an extra tap. It's on by default and only affects creating an
|
||||
event — editing never grabs focus — and a new **Focus title on new event**
|
||||
switch (Settings → New event form) turns it off. Thanks to @abrossimow for the
|
||||
suggestion ([#10]).
|
||||
|
||||
## [2.11.1] — 2026-06-28
|
||||
|
||||
### Fixed
|
||||
- Calendula can now be set as your default calendar app. It registers the
|
||||
calendar-app intent filters the system uses, so it appears in the chooser when
|
||||
you tap a date in a launcher or clock — and opening one takes you straight to
|
||||
that day. Android has no way for an app to make itself the default, so you pick
|
||||
it once from the system picker. Thanks to @abrossimow for the report ([#9]).
|
||||
|
||||
## [2.11.0] — 2026-06-27
|
||||
|
||||
### Added
|
||||
- Start the week on any day. The **Week starts on** setting (Settings →
|
||||
Appearance) now offers every weekday — not just Monday or Sunday — alongside
|
||||
the automatic, locale-based default. Thanks to @zmaherdev for the suggestion
|
||||
([#3]).
|
||||
- Choose a 12- or 24-hour clock. A new **Time format** setting (Settings →
|
||||
Appearance) lets you force a 12-hour (2:00 PM) or 24-hour (14:00) clock, or
|
||||
follow the system setting automatically. It applies everywhere times appear —
|
||||
the week and day timelines, agenda, event details, search and reminders — so
|
||||
the format is now consistent across the whole app. Thanks to @zmaherdev for
|
||||
the suggestion ([#6]).
|
||||
- Optional hour lines in the timeline. A new **Hour lines** switch (Settings →
|
||||
Appearance) draws a faint separator at each hour in the week and day views,
|
||||
making it easier to see when events start and end. Off by default. Thanks to
|
||||
@zmaherdev for the suggestion ([#5]).
|
||||
- Limit how far ahead the agenda looks, and switch it on the fly. New **Agenda
|
||||
range** and **Agenda widget range** settings (Settings → Appearance) let the
|
||||
agenda screen and its home-screen widget each show just today, the rest of this
|
||||
week, the rest of this month, a rolling 7 or 30 days, or a custom number of
|
||||
days — "This week" follows your week-start. The agenda also gains a bar at the
|
||||
top naming the exact dates in view, with a button to switch the range just for
|
||||
the current session (it resets when you reopen the app); the bar can be turned
|
||||
off. Thanks to @zmaherdev for the suggestion ([#4]).
|
||||
- Automatic backup of local calendars. A new **Automatic backup** option
|
||||
(Settings → Calendars → Backup) periodically exports your local calendars to an
|
||||
`.ics` file in a folder you choose, on an interval you set (from 30 minutes up).
|
||||
Useful for file-based syncing such as Syncthing, or simply as a safety net — it
|
||||
is a one-way export and never touches your synced accounts. Thanks to @shield
|
||||
for the discussion that inspired it ([#7], [#8]).
|
||||
- Field icons in the event-form settings. Each optional-field toggle (Settings →
|
||||
Event form) now shows the same icon the field uses in the new-event form, so
|
||||
the list is easier to scan.
|
||||
- Help translate Calendula. A **Help translate** link at the top of **Settings →
|
||||
App language** opens the project's Weblate, where you can add or improve a
|
||||
language in your browser — no coding needed. Contributions in any language are
|
||||
welcome.
|
||||
|
||||
### Changed
|
||||
- Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar,
|
||||
agenda), and the agenda's empty state now reads "You're all caught up".
|
||||
- Reworked **Settings → Calendars**. Local and synced calendars are now grouped
|
||||
into collapsible, source-branded cards — each account shows its app's icon —
|
||||
with a per-account menu to enable or disable all of its calendars at once or
|
||||
open the account in its source app.
|
||||
- Reorganised **Settings → Notifications**: reliable-delivery and snooze settings
|
||||
moved up with the other global reminder options, and the per-calendar reminder
|
||||
overrides now fold into a single expandable section.
|
||||
- Moved **Add Quick Settings tile** out of the event-form section to its own
|
||||
top-level entry in Settings.
|
||||
|
||||
## [2.10.0] — 2026-06-25
|
||||
|
||||
### Added
|
||||
- Turn a calendar off across the whole app. Settings → Calendars now has a
|
||||
switch on every calendar — both your own and synced ones. Switching one off
|
||||
removes it everywhere: its events disappear from all views and search, and it
|
||||
drops out of the drawer's hide/show filter, the event-form calendar picker and
|
||||
the import target picker. Unlike hiding (the quick per-view checkbox in the
|
||||
drawer), a disabled calendar leaves the app entirely until you turn it back on.
|
||||
Nothing is deleted and no other app is affected — it's a Calendula-only view
|
||||
choice — so you can re-enable it any time from the same screen, where disabled
|
||||
calendars stay listed but dimmed.
|
||||
- An optional way to support development. Settings → About now has a "Support
|
||||
development" button that opens Ko-fi in your browser. It's a plain donation
|
||||
link with no perks attached, and it needs no new permissions — Calendula still
|
||||
has no internet access of its own and just hands the link to your browser.
|
||||
|
||||
## [2.9.0] — 2026-06-25
|
||||
|
||||
### Added
|
||||
- Choose the view Calendula opens on. A new **Default view** setting (Settings →
|
||||
Appearance) lets you pick Month, Week, Day or Agenda as the view shown each
|
||||
time you start the app, instead of always opening on Week. Thanks to
|
||||
@devinside for the suggestion ([#1]).
|
||||
- The month widget is now interactive: tap any day to open it, or tap an event to
|
||||
open its details. Previously only the month's prev/next/today controls
|
||||
responded. Thanks to @devinside for spotting this ([#2]).
|
||||
- Predictive back. Swiping back from a screen — an event, the editor, search or
|
||||
settings — now follows your finger, shrinking the screen to preview what's
|
||||
behind so you can see where Back will take you before you let go; slide it back
|
||||
to cancel. The preview needs Android 14 or newer; on older versions Back works
|
||||
as before.
|
||||
- Respect for "Remove animations". If you've turned animations off in your
|
||||
device's Accessibility settings, Calendula now honours it everywhere: motion
|
||||
collapses to a quick fade and the back preview is skipped.
|
||||
|
||||
### Changed
|
||||
- Smoother, more consistent motion throughout. Expanding sections, list updates
|
||||
in search and agenda, and the onboarding screens now animate the same way
|
||||
across the app, instead of some places sliding or growing while others popped
|
||||
in. Switching between Month, Week, Day and Agenda now cross-fades rather than
|
||||
snapping, while paging within a view keeps its slide.
|
||||
|
||||
### Fixed
|
||||
- Home-screen widgets now back out the way you came in. A day or event opened
|
||||
from the Agenda widget keeps you in the agenda context, and from the Month
|
||||
widget in the month context — pressing Back returns there instead of dropping
|
||||
you on the week view. More broadly, the app now keeps a real view history:
|
||||
switching views and drilling into a day are retraced by Back one step at a
|
||||
time, down to your default view before the app exits. Thanks to @devinside for
|
||||
reporting ([#2]).
|
||||
|
||||
## [2.8.0] — 2026-06-23
|
||||
|
||||
### Added
|
||||
- Find events fast. A search button in the top bar of every calendar view opens
|
||||
a search box — type a couple of letters and matching events (by title,
|
||||
location or description) appear, soonest first with past events below. Tap a
|
||||
result to open it. Search covers your whole calendar, not just what's on
|
||||
screen, and skips calendars you've hidden. A recurring event shows its next
|
||||
occurrence rather than the date the series first started.
|
||||
- A current-time line in the day and week views. A thin coloured line marks the
|
||||
present moment across today's column, so you can see at a glance where you are
|
||||
in the day. It updates every minute and only appears when today is in view.
|
||||
- Add and remove event guests. The create/edit form now has a Guests section:
|
||||
add people by email or pick them from your contacts, mark each as required or
|
||||
optional, and remove them. Calendula never sends invitations itself (it has no
|
||||
internet access) — it only records the guests; if the event lives on a synced
|
||||
account, that account may email them when it syncs, and on a local calendar no
|
||||
one is notified. The form tells you which applies. Picking a guest from
|
||||
contacts needs no contacts permission.
|
||||
- Pick a location from your contacts. A contacts button beside the location
|
||||
field drops a contact's address straight into an event — handy for a meeting
|
||||
at someone's home or office. Like the guest picker, it needs no contacts
|
||||
permission.
|
||||
- A "New event" Quick Settings tile. Add it to your quick settings to jump
|
||||
straight into the new-event form from anywhere. Settings → New event has a
|
||||
one-tap button to add the tile (Android 13+); on older versions you can add it
|
||||
from the quick-settings editor.
|
||||
- Snooze and dismiss buttons on reminder notifications. Dismiss clears the
|
||||
reminder; snooze hides it and brings it back after a delay you pick in
|
||||
Settings → Notifications (5 to 60 minutes, default 10). Android's calendar
|
||||
system won't re-post a reminder on its own, so Calendula schedules an exact
|
||||
alarm to bring a snoozed one back on time.
|
||||
|
||||
### Changed
|
||||
- Event details now show each guest's email beneath their name, instead of only
|
||||
when no name is available.
|
||||
- Crash and problem reports now open on the project's public Codeberg tracker,
|
||||
where anyone can register and file an issue. Nothing is sent automatically —
|
||||
you still review the report and submit it yourself in the browser.
|
||||
|
||||
## [2.7.5] — 2026-06-21
|
||||
|
||||
### Changed
|
||||
@@ -534,3 +1257,69 @@ automatically, with zero telemetry and no internet permission.
|
||||
- Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner
|
||||
- F-Droid metadata stubs (DE + EN short/full descriptions)
|
||||
- `.planning/` project-tracking documents
|
||||
|
||||
[#1]: https://codeberg.org/jlmakiola/calendula/issues/1
|
||||
[#2]: https://codeberg.org/jlmakiola/calendula/issues/2
|
||||
[#3]: https://codeberg.org/jlmakiola/calendula/issues/3
|
||||
[#4]: https://codeberg.org/jlmakiola/calendula/issues/4
|
||||
[#5]: https://codeberg.org/jlmakiola/calendula/issues/5
|
||||
[#6]: https://codeberg.org/jlmakiola/calendula/issues/6
|
||||
[#7]: https://codeberg.org/jlmakiola/calendula/issues/7
|
||||
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8
|
||||
[#9]: https://codeberg.org/jlmakiola/calendula/issues/9
|
||||
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10
|
||||
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
|
||||
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
|
||||
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
|
||||
[#15]: https://codeberg.org/jlmakiola/calendula/issues/15
|
||||
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
||||
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
|
||||
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
||||
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
|
||||
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
||||
[#22]: https://codeberg.org/jlmakiola/calendula/issues/22
|
||||
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
|
||||
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25
|
||||
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27
|
||||
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
|
||||
[#21]: https://codeberg.org/jlmakiola/calendula/issues/21
|
||||
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
|
||||
[#31]: https://codeberg.org/jlmakiola/calendula/issues/31
|
||||
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
|
||||
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
||||
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||
[#36]: https://codeberg.org/jlmakiola/calendula/issues/36
|
||||
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
||||
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
|
||||
[#35]: https://codeberg.org/jlmakiola/calendula/issues/35
|
||||
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
|
||||
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
|
||||
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
||||
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
||||
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
||||
[#51]: https://codeberg.org/jlmakiola/calendula/issues/51
|
||||
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
|
||||
[#38]: https://codeberg.org/jlmakiola/calendula/issues/38
|
||||
[#53]: https://codeberg.org/jlmakiola/calendula/issues/53
|
||||
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60
|
||||
[#65]: https://codeberg.org/jlmakiola/calendula/issues/65
|
||||
[#74]: https://codeberg.org/jlmakiola/calendula/issues/74
|
||||
[#42]: https://codeberg.org/jlmakiola/calendula/issues/42
|
||||
[#44]: https://codeberg.org/jlmakiola/calendula/issues/44
|
||||
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70
|
||||
[#75]: https://codeberg.org/jlmakiola/calendula/issues/75
|
||||
[#76]: https://codeberg.org/jlmakiola/calendula/issues/76
|
||||
[#78]: https://codeberg.org/jlmakiola/calendula/issues/78
|
||||
[#77]: https://codeberg.org/jlmakiola/calendula/issues/77
|
||||
[#79]: https://codeberg.org/jlmakiola/calendula/issues/79
|
||||
[#81]: https://codeberg.org/jlmakiola/calendula/issues/81
|
||||
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
||||
[#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
|
||||
|
||||
184
CONTRIBUTING.md
Normal file
184
CONTRIBUTING.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Contributing to Calendula
|
||||
|
||||
Calendula is a Material 3 Expressive calendar app that lives strictly on top of
|
||||
Android's `CalendarContract` — no app database, no sync stack, no network access.
|
||||
That constraint shapes most review comments, so
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) is worth skimming before you write
|
||||
code. This file is the practical how.
|
||||
|
||||
**[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home** —
|
||||
issues, pull requests, releases. The self-hosted Gitea instance referenced in the
|
||||
release docs is build infrastructure only; there is nothing to contribute there.
|
||||
Be decent to the people you meet in the tracker.
|
||||
|
||||
## Start with an issue
|
||||
|
||||
| You want to | Do this |
|
||||
|---|---|
|
||||
| Add a feature | **Open an issue first** and wait for a go-ahead |
|
||||
| Fix a bug | Open an issue, then a pull request |
|
||||
| Fix a typo, a comment, or docs | Just open the pull request |
|
||||
| Add or fix a translation | **Don't** — [use Weblate](#translations) |
|
||||
|
||||
Features get an opinion before they get code: whether Calendula should do a
|
||||
thing at all is the one decision a patch can't make. A feature PR that arrives
|
||||
without a discussed issue may be closed unmerged even when the code is good —
|
||||
please don't spend a weekend on one first.
|
||||
|
||||
Bugs are more straightforward, but still start with an issue: it's what carries
|
||||
the milestone and gives the changelog something to link.
|
||||
|
||||
Issue templates cover bug, crash, feature and question. For a crash, let the app
|
||||
do the work — **Settings → Report a problem**, or the prompt shown after a crash,
|
||||
captures the stack trace and prefills the form. The report contains app, Android
|
||||
and device versions plus the trace; no calendar content, no personal data.
|
||||
|
||||
## Which branch to target
|
||||
|
||||
Calendula releases by merging a version bump into `main`, so `main` is a release
|
||||
trigger rather than a staging area. Work is assembled on release branches first.
|
||||
|
||||
Once your issue has a milestone, that milestone names your branch:
|
||||
|
||||
| Milestone | Target branch |
|
||||
|---|---|
|
||||
| `2.18.0` | `release/v2.18.0` |
|
||||
|
||||
Every milestone has a matching branch. If it's somehow missing, target `main` and
|
||||
mention it in the PR — it will be retargeted. Don't pick an older release branch:
|
||||
they're kept after shipping, so the newest one isn't necessarily yours.
|
||||
|
||||
## Translations
|
||||
|
||||
**Never edit a `values-*/strings.xml` file in a pull request** — German included.
|
||||
Translations are owned by a self-hosted Weblate that writes to this repository
|
||||
directly, and a hand-edit is overwritten on the next sync.
|
||||
|
||||
→ **[Translate Calendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
|
||||
|
||||
Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
|
||||
picks it up and offers it to translators. Partial translations are expected and
|
||||
fine — missing keys are informational. Stale and orphaned keys are not, so run
|
||||
|
||||
```sh
|
||||
python3 scripts/check_translations.py
|
||||
```
|
||||
|
||||
before pushing. It reports those more clearly than lint's `MissingTranslation`
|
||||
does.
|
||||
|
||||
## Build & test
|
||||
|
||||
```sh
|
||||
git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git
|
||||
```
|
||||
|
||||
The `floret-kit` submodule is a composite build compiled from source. An
|
||||
existing clone needs `git submodule update --init --recursive`, or nothing
|
||||
resolves.
|
||||
|
||||
- **JDK 17** — not newer; the Android Gradle Plugin requires exactly 17. Set
|
||||
`JAVA_HOME` if your default differs.
|
||||
- **Android SDK** — platform 37 (`compileSdk`) and build-tools 36.0.0, located
|
||||
via `ANDROID_HOME` or a gitignored `local.properties` with `sdk.dir`. If you
|
||||
go the `local.properties` route the included build needs its own copy at
|
||||
`floret-kit/local.properties`; `ANDROID_HOME` covers both at once and is the
|
||||
easier path.
|
||||
|
||||
The Gradle wrapper is checked in, so no system Gradle is needed.
|
||||
|
||||
```sh
|
||||
./gradlew lint test assembleDebug # roughly what CI runs
|
||||
```
|
||||
|
||||
A single test class, or a pattern:
|
||||
|
||||
```sh
|
||||
./gradlew testDebugUnitTest --tests "de.jeanlucmakiola.calendula.domain.SimpleRecurrenceTest"
|
||||
./gradlew testDebugUnitTest --tests "*SimpleRecurrence*"
|
||||
```
|
||||
|
||||
CI reports one `CI` check per pull request: `lintDebug`, `testDebugUnitTest`,
|
||||
`assembleDebug`, and a Trivy scan. Pull requests touching only docs, F-Droid
|
||||
metadata or the licence skip the Android build and go green quickly. More detail
|
||||
in [`docs/BUILDING.md`](docs/BUILDING.md).
|
||||
|
||||
## The rules
|
||||
|
||||
These are the ones that turn into review comments.
|
||||
|
||||
1. **No network.** Calendula holds no `INTERNET` permission, and that's a
|
||||
feature rather than an oversight. Anything that would need one is a product
|
||||
decision before it's a patch — the crash reporter deliberately opens a
|
||||
prefilled web issue instead of posting anything itself.
|
||||
2. **The provider is the only database.** No Room, no cache, no local mirror of
|
||||
events. `CalendarContract` is the single source of truth, which is also why
|
||||
externally synced changes work for free.
|
||||
3. **Don't patch UI state after a write.** A `ContentObserver` re-queries and
|
||||
views recompose from fresh provider state. Hand-patching a list after saving
|
||||
appears to work, then quietly diverges from what the provider actually stored.
|
||||
4. **`domain/` has no Android imports.** Models, validation, recurrence
|
||||
rendering, conflict snapshots and the `.ics` codec stay pure Kotlin so they
|
||||
remain JVM-testable.
|
||||
5. **Tests run on the JVM.** JUnit 5 + Truth + Turbine. The seams exist for you:
|
||||
fake the data source (`FakeCalendarDataSource`), and feed mappers plain maps
|
||||
through `ColumnReader` instead of cursors. Instrumented tests are a last
|
||||
resort, not a default.
|
||||
6. **Read before touching the subtle pipelines.** Recurring writes (UNTIL vs
|
||||
DURATION, exception URIs, series splits), save-conflict detection and reminder
|
||||
delivery (post-before-mark) follow provider-driven rules that are documented
|
||||
in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) and are not guessable from
|
||||
the code alone.
|
||||
7. **Don't break reproducible builds.** `vcsInfo`, `dependenciesInfo` and the AGP
|
||||
metadata block are disabled on purpose so the official F-Droid repo can verify
|
||||
our binary against a from-source rebuild.
|
||||
`scripts/check_reproducible_release.sh` runs on every pull request, including
|
||||
docs-only ones.
|
||||
|
||||
## UI conventions
|
||||
|
||||
Material 3 Expressive throughout, built from the system's own tokens and
|
||||
components — colour-scheme tokens rather than hardcoded colours, `ListItem` for
|
||||
settings rows.
|
||||
|
||||
**Selection pickers are full-screen.** Every browse-style "choose one" surface
|
||||
uses floret-kit's `FullScreenPicker` / `OptionPicker`; one that needs a commit or
|
||||
extra action passes it through the picker's `actions` slot. The exception is the
|
||||
recurring-scope chooser (*this / this and following / all*), which stays a
|
||||
compact dialog — a two- or three-option decision reads better as a popup than as
|
||||
a nearly empty screen. `AlertDialog` is for plain confirmations only, and radio-
|
||||
or text-list dialogs aren't used at all.
|
||||
|
||||
Shared UI machinery lives in the `floret-kit` submodule and has
|
||||
[its own contributing guide](https://codeberg.org/jlmakiola/floret-kit/src/branch/main/CONTRIBUTING.md);
|
||||
changing it means a pull request against that repository plus a submodule bump
|
||||
here.
|
||||
|
||||
## Commits & pull requests
|
||||
|
||||
Conventional commits, scoped to the area you touched:
|
||||
|
||||
```
|
||||
fix(calendars): keep an event's own calendar when it is switched off
|
||||
feat(month): pull-to-expand the split view (#38)
|
||||
docs(architecture): record what the second review pass changed
|
||||
```
|
||||
|
||||
Types in use: `feat` `fix` `docs` `refactor` `style` `chore` `ci` `build`
|
||||
`revert`. Reference the issue in the subject or the body. Keep commits small —
|
||||
small commits revert cleanly, which matters more here than a tidy history.
|
||||
|
||||
If your change is user-visible, add an entry under `## [Unreleased]` in
|
||||
[`CHANGELOG.md`](CHANGELOG.md). Match the surrounding voice: entries describe
|
||||
what changed *for the person using the app*, and why, not what changed in the
|
||||
code. Link the issue and add its reference at the bottom of the file. It may get
|
||||
reworded when the release is cut, so don't agonise over it.
|
||||
|
||||
Please don't commit planning or design documents. Code, tests, architecture notes
|
||||
and the changelog land; the reasoning belongs in the commit message and the
|
||||
issue.
|
||||
|
||||
## Licence
|
||||
|
||||
Calendula is [MIT](LICENSE). By contributing you agree your changes ship under
|
||||
the same licence.
|
||||
10
Gemfile
Normal file
10
Gemfile
Normal file
@@ -0,0 +1,10 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
# fastlane is used ONLY to upload the release bundle to Google Play
|
||||
# (see fastlane/Fastfile). It is not part of the build or the signing path, so
|
||||
# it never runs on a PR — only in release.yaml's `play` job.
|
||||
#
|
||||
# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is
|
||||
# committed on purpose: this resolves an uploader's transitive deps, not the
|
||||
# app's, and none of it affects the reproducible release build.
|
||||
gem "fastlane", "2.237.0"
|
||||
142
README.md
142
README.md
@@ -8,7 +8,7 @@
|
||||
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
|
||||
|
||||
<p>
|
||||
<a href="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions"><img src="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
|
||||
<a href="https://codeberg.org/jlmakiola/calendula/actions"><img src="https://codeberg.org/jlmakiola/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
|
||||
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
|
||||
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
|
||||
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
|
||||
@@ -16,11 +16,20 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
|
||||
</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">
|
||||
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
|
||||
|
||||
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
|
||||
|
||||
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="16%" alt="Week view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="16%" alt="Month view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-day.png" width="16%" alt="Day view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="16%" alt="Event detail">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-agenda.png" width="16%" alt="Agenda view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="16%" alt="Reminder onboarding">
|
||||
</p>
|
||||
|
||||
</div>
|
||||
@@ -62,19 +71,41 @@ database, no sync stack reinvented.
|
||||
|
||||
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
|
||||
expressive motion and shapes, light/dark theme
|
||||
- German and English UI, per-app language setting
|
||||
- English and German UI plus community translations (Spanish, French, Italian,
|
||||
Polish, and more in progress), per-app language setting — and [open to more
|
||||
languages](#-translations)
|
||||
- **Zero telemetry, zero analytics, no internet permission** — your data
|
||||
never leaves the device
|
||||
|
||||
## 📦 Install
|
||||
|
||||
Calendula ships through a self-hosted F-Droid repository; every version tag
|
||||
is built, signed, and published there automatically.
|
||||
Pick whichever channel you already use — they all install the same app:
|
||||
|
||||
1. Install an F-Droid client ([F-Droid](https://f-droid.org), Droid-ify, Neo
|
||||
Store, …).
|
||||
2. Add the repository — open this link on your phone, or paste it under
|
||||
*Settings → Repositories → Add*:
|
||||
| Channel | Updates | Notes |
|
||||
| --- | --- | --- |
|
||||
| [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup |
|
||||
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; needs the repo added once |
|
||||
| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automated by Obtainium |
|
||||
| [Google Play](#google-play-coming-soon) | — | Coming soon |
|
||||
| [Build from source](#build-from-source) | Whenever you build | Full control |
|
||||
|
||||
### F-Droid (recommended)
|
||||
|
||||
Calendula is on the **official [F-Droid](https://f-droid.org) repository** —
|
||||
just search for **Calendula** in any F-Droid client, or
|
||||
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
|
||||
|
||||
F-Droid rebuilds from source on its own schedule, so a new version usually
|
||||
shows up there a few days after release.
|
||||
|
||||
### Self-hosted F-Droid repo (fastest updates)
|
||||
|
||||
Every release is built, signed, and published to a self-hosted F-Droid
|
||||
repository as part of the release pipeline, so it lands there first. Add it once
|
||||
and your F-Droid client handles updates from then on:
|
||||
|
||||
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
|
||||
link below on your phone):
|
||||
|
||||
```
|
||||
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425
|
||||
@@ -84,36 +115,79 @@ is built, signed, and published there automatically.
|
||||
fingerprint (SHA-256):
|
||||
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
|
||||
|
||||
3. Refresh, search for **Calendula**, install. Updates arrive like any
|
||||
other F-Droid app.
|
||||
2. Refresh, search for **Calendula**, install.
|
||||
|
||||
Alternatively, build from source — see below.
|
||||
### Codeberg release / Obtainium
|
||||
|
||||
## 🛠 Building
|
||||
If you'd rather not use F-Droid at all, every release is also published on
|
||||
**[Codeberg](https://codeberg.org/jlmakiola/calendula/releases)** with the
|
||||
signed APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum attached — download
|
||||
and install it directly.
|
||||
|
||||
Requires Android SDK 36+ and JDK 17. The Gradle wrapper is checked in:
|
||||
For automatic updates from that channel, use
|
||||
**[Obtainium](https://github.com/ImranR98/Obtainium)** — on the phone,
|
||||
**[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula)**,
|
||||
or do it by hand: *Add App* → paste `https://codeberg.org/jlmakiola/calendula`
|
||||
→ *Add*. Either way, Obtainium tracks the releases and prompts you when a new
|
||||
one appears.
|
||||
|
||||
```bash
|
||||
./gradlew assembleDebug # debug APK
|
||||
./gradlew test # JVM unit tests
|
||||
./gradlew lint # Android lint
|
||||
```
|
||||
### Google Play (coming soon)
|
||||
|
||||
If your default JDK is not 17, set `JAVA_HOME` explicitly.
|
||||
Calendula is on its way to Google Play as an additional channel. It isn't live
|
||||
yet — this section gets a link once it is. Play builds will be signed with
|
||||
Google's key rather than mine, so switching between Play and any other channel
|
||||
will require an uninstall.
|
||||
|
||||
## 🏗 Architecture
|
||||
> **Testers wanted.** Play requires a round of closed testing before the app can
|
||||
> go public, and I'm still looking for testers. If you'd like to help, email
|
||||
> **[business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de)** with the
|
||||
> Google account address you want to use — that address is what I need to add you
|
||||
> to the closed test.
|
||||
|
||||
Single-activity Compose app, layered `UI → Repository → DataSource →
|
||||
CalendarContract`, observer-driven refresh, JVM-first tests. The full tour —
|
||||
including the recurring-write and reminder pipelines — lives in
|
||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
### Build from source
|
||||
|
||||
## 🗺 Roadmap
|
||||
The build is a plain Gradle build with no proprietary dependencies — see
|
||||
**[docs/BUILDING.md](docs/BUILDING.md)** (note the `floret-kit` submodule).
|
||||
|
||||
Shipped: read (v1.0), write (v1.1–v2.0), reminder delivery (v1.4).
|
||||
Next up: power-user features — widget, search, tablet layouts. The living
|
||||
roadmap is in [.planning/ROADMAP.md](.planning/ROADMAP.md), the release
|
||||
history in [CHANGELOG.md](CHANGELOG.md).
|
||||
<sub>Official F-Droid, the self-hosted repo, and the Codeberg releases all share
|
||||
the same signing key, so you can switch freely between them without
|
||||
reinstalling.</sub>
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **[Contributing](CONTRIBUTING.md)** — how to report, propose, and patch
|
||||
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
|
||||
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
||||
- **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Bug reports, ideas, and patches are all welcome on
|
||||
**[Codeberg](https://codeberg.org/jlmakiola/calendula/issues)**.
|
||||
|
||||
The short version: **start with an issue.** Features get a yes-or-no before they
|
||||
get code, and both features and bugs are assigned a milestone whose
|
||||
`release/vX.Y.Z` branch your pull request then targets. Typo and docs fixes can
|
||||
skip straight to a pull request. Translations don't go through pull requests at
|
||||
all — [Weblate owns them](#-translations).
|
||||
|
||||
Read **[CONTRIBUTING.md](CONTRIBUTING.md)** before writing code: it covers the
|
||||
workflow, the build (note the `floret-kit` submodule), and the architectural
|
||||
rules a change is reviewed against.
|
||||
|
||||
## 🌍 Translations
|
||||
|
||||
Calendula ships in English and German, with community translations in Arabic,
|
||||
Chinese, French, Italian, Polish, Portuguese, Russian, and Spanish at varying
|
||||
degrees of completeness — partial is fine, untranslated strings simply fall back
|
||||
to English. You're warmly invited to add or finish your language. Translations
|
||||
are managed on a self-hosted **Weblate**:
|
||||
|
||||
**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
|
||||
|
||||
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**.
|
||||
|
||||
## 📜 License
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ android {
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||
versionCode = 20705
|
||||
versionName = "2.7.5"
|
||||
versionCode = 21800
|
||||
versionName = "2.18.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -90,6 +90,8 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
// BuildConfig.DEBUG gates the in-app debug ribbon (see DebugRibbon).
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
|
||||
@@ -111,10 +113,13 @@ android {
|
||||
lint {
|
||||
// Community translations are expected to be partial — a missing string
|
||||
// falls back to the English base at runtime — so don't fail the build on
|
||||
// it. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
|
||||
// it. Likewise a translated <plurals> may not fill every CLDR quantity
|
||||
// form its locale defines (e.g. Arabic needs "zero"); the missing form
|
||||
// falls back to "other" at runtime, so MissingQuantity is informational
|
||||
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
|
||||
// check_translations.py guards the same invariants with clearer,
|
||||
// translator-facing messages.
|
||||
informational += "MissingTranslation"
|
||||
informational += listOf("MissingTranslation", "MissingQuantity")
|
||||
}
|
||||
|
||||
testOptions {
|
||||
@@ -152,10 +157,19 @@ dependencies {
|
||||
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(libs.androidx.documentfile)
|
||||
|
||||
implementation(libs.androidx.glance.appwidget)
|
||||
implementation(libs.androidx.glance.material3)
|
||||
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation("de.jeanlucmakiola.floret:core-time")
|
||||
implementation("de.jeanlucmakiola.floret:core-locale")
|
||||
implementation("de.jeanlucmakiola.floret:core-crash")
|
||||
implementation("de.jeanlucmakiola.floret:core-reminders")
|
||||
implementation("de.jeanlucmakiola.floret:identity")
|
||||
implementation("de.jeanlucmakiola.floret:components")
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
|
||||
24
app/proguard-rules.pro
vendored
24
app/proguard-rules.pro
vendored
@@ -16,6 +16,18 @@
|
||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||
-dontwarn androidx.room.paging.**
|
||||
|
||||
# Glance runs an @Composable's `actionRunCallback<T>()` by persisting the
|
||||
# callback's fully-qualified class name into the click PendingIntent, then
|
||||
# reflectively instantiating it (Class.forName(name).newInstance()) when the tap
|
||||
# fires. Under R8 full mode (AGP 9 default) these ActionCallback classes — only
|
||||
# ever referenced reflectively — get renamed or have their no-arg constructor
|
||||
# stripped, so the lookup fails silently and the tap does nothing. In the month
|
||||
# and agenda widgets that broke every run-callback control (the prev/next/today
|
||||
# month arrows and the agenda refresh) in release builds while actionStartActivity
|
||||
# taps, which ride a PendingIntent and need no reflection, kept working. Keep
|
||||
# every ActionCallback's name and constructor intact.
|
||||
-keep class * implements androidx.glance.appwidget.action.ActionCallback { <init>(...); }
|
||||
|
||||
# WorkManager instantiates an InputMerger reflectively (Class.newInstance) from
|
||||
# the fully-qualified class name persisted in the WorkSpec, so the class must
|
||||
# keep both its name and a no-arg constructor. Glance renders every widget
|
||||
@@ -26,3 +38,15 @@
|
||||
# SessionWorker never ran, and widgets were stuck on their loading layout
|
||||
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
|
||||
-keep class * extends androidx.work.InputMerger { <init>(...); }
|
||||
|
||||
# Glance identifies a widget by its GlanceAppWidget subclass's *canonical name*:
|
||||
# GlanceAppWidgetManager persists a providerName -> receivers map under that
|
||||
# string, and `updateAll` looks the widget's app-widget ids up through it. Under
|
||||
# R8 full mode (AGP 9 default) MonthWidget and AgendaWidget — same supertype,
|
||||
# same overrides, no distinguishing members — were horizontally merged into one
|
||||
# class, so both receivers registered under the *same* provider name and
|
||||
# `AgendaWidget().updateAll()` resolved the month widget's id too, redrawing a
|
||||
# placed month widget as the agenda one on the next data change (#89). Keeping
|
||||
# the real names also survives app updates, which would otherwise renumber the
|
||||
# obfuscated name and orphan the stored mapping.
|
||||
-keep class * extends androidx.glance.appwidget.GlanceAppWidget
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -31,7 +32,12 @@ class CalendarRepositorySmokeTest {
|
||||
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||
produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") },
|
||||
)
|
||||
return CalendarRepositoryImpl(dataSource, CalendarPrefs(store), Dispatchers.IO)
|
||||
return CalendarRepositoryImpl(
|
||||
dataSource,
|
||||
CalendarPrefs(store),
|
||||
SettingsPrefs(store),
|
||||
Dispatchers.IO,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
10
app/src/debug/res/values/colors.xml
Normal file
10
app/src/debug/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Debug-only launcher-icon background. Production is slate (#5C6B7A); the
|
||||
debug build paints the adaptive-icon background burnt orange instead, so the
|
||||
debug icon reads at a glance as "not the real app" on the home screen. The
|
||||
off-white foreground mark contrasts on both. See drawable/ic_launcher_background.
|
||||
-->
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFB23B00</color>
|
||||
</resources>
|
||||
10
app/src/debug/res/values/strings.xml
Normal file
10
app/src/debug/res/values/strings.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Debug-build resource overrides. Merged on top of src/main for the `debug`
|
||||
build type only (release/releaseTest keep the production values), so the
|
||||
debug app is unmistakable on the launcher: its own label, alongside the
|
||||
real app thanks to the `.debug` applicationId suffix.
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">Calendula Debug</string>
|
||||
</resources>
|
||||
@@ -5,6 +5,15 @@
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!--
|
||||
Optional and feature-gated: only the "Contact special dates" feature reads
|
||||
contacts, and only after the user enables it and grants this at runtime
|
||||
(never requested at startup). Everything stays offline — birthdays and
|
||||
other contact dates are mirrored one-way into local calendars; contacts
|
||||
are never written and nothing leaves the device (the app has no INTERNET
|
||||
permission). See docs/design/contact-special-dates.md.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<!--
|
||||
Lets the "Reliable delivery" setting open the direct system dialog to
|
||||
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
||||
@@ -13,6 +22,25 @@
|
||||
-->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<!--
|
||||
Re-fire a snoozed reminder at an exact time (the calendar provider won't —
|
||||
its alert is already fired). USE_EXACT_ALARM is auto-granted to calendar
|
||||
apps on API 33+; SCHEDULE_EXACT_ALARM covers API 31–32 (user-revocable,
|
||||
with an inexact fallback if withheld). F-Droid-clean: no Play allowlisting.
|
||||
-->
|
||||
<uses-permission
|
||||
android:name="android.permission.SCHEDULE_EXACT_ALARM"
|
||||
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
|
||||
@@ -28,6 +56,7 @@
|
||||
<application
|
||||
android:name=".CalendulaApp"
|
||||
android:allowBackup="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -42,31 +71,189 @@
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- The MAIN/LAUNCHER entry (the launcher icon + its label) lives on
|
||||
the two <activity-alias> below, so the app name can be switched at
|
||||
runtime (issue #44). MainActivity keeps every other filter. -->
|
||||
|
||||
<!-- Be selectable as the system calendar app. Android has no API for
|
||||
an app to make itself the default, so registering the filters
|
||||
launchers and the OS use is what lets the user pick Calendula
|
||||
from the system chooser when a date action fires (issue #9).
|
||||
APP_CALENDAR is the "open the calendar app" action; the VIEW
|
||||
filters below catch a tapped date. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.APP_CALENDAR" />
|
||||
</intent-filter>
|
||||
<!-- A launcher/clock date tap fires ACTION_VIEW on the provider's
|
||||
time Uri (content://com.android.calendar/time/<epochMillis>);
|
||||
some surfaces use the time/epoch mime type. We open the day view
|
||||
on that date (MainActivity.calendarTimeDateOrNull). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data
|
||||
android:scheme="content"
|
||||
android:host="com.android.calendar"
|
||||
android:pathPrefix="/time" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="time/epoch" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||
<!-- Open a .ics/.vcs file (file manager / email attachment / browser).
|
||||
The three MIME types cover the common labels the same calendar
|
||||
data arrives under: iCalendar 2.0 (text/calendar), the older
|
||||
vCalendar 1.0 / .vcs (text/x-vcalendar), and application/ics some
|
||||
mail apps emit — Android cross-products the scheme and mimeType
|
||||
tags, so each MIME is accepted on both schemes (matches Etar). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="content" android:mimeType="text/calendar" />
|
||||
<data android:scheme="file" android:mimeType="text/calendar" />
|
||||
<data android:scheme="content" />
|
||||
<data android:scheme="file" />
|
||||
<data android:mimeType="text/calendar" />
|
||||
<data android:mimeType="text/x-vcalendar" />
|
||||
<data android:mimeType="application/ics" />
|
||||
</intent-filter>
|
||||
<!-- Receive a .ics shared from another app. -->
|
||||
<!-- Same .ics/.vcs data arriving mislabelled as a generic download —
|
||||
application/octet-stream — the way many mail clients, browsers and
|
||||
file managers hand off attachments. Matched by file extension, so
|
||||
this stays a separate filter: a pathPattern here must not narrow
|
||||
the MIME-typed VIEW filter above (that one has no path and must
|
||||
keep matching regardless of name). The import handler ignores the
|
||||
MIME type, so a let-through octet-stream .ics imports normally.
|
||||
Best-effort: pathPattern is reliable for file:// (and content://
|
||||
whose path carries the filename); content:// URIs that expose no
|
||||
name still fall back to the MIME-typed filter above. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="content" />
|
||||
<data android:scheme="file" />
|
||||
<data android:host="*" />
|
||||
<data android:mimeType="application/octet-stream" />
|
||||
<data android:pathPattern=".*\\.ics" />
|
||||
<data android:pathPattern=".*\\.vcs" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Receive a .ics/.vcs shared from another app (same MIME set). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/calendar" />
|
||||
<data android:mimeType="text/x-vcalendar" />
|
||||
<data android:mimeType="application/ics" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
|
||||
to create a new event, the way the AOSP calendar accepts it:
|
||||
ACTION_INSERT on the events *dir* mime type, carrying the new
|
||||
event's fields as CalendarContract extras
|
||||
(MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
|
||||
dir mime is AOSP's "edit a new event" — i.e. create — so it maps
|
||||
to the same prefilled create form. (The far more common *item*-
|
||||
typed INSERT — the form the Android docs' example and apps like
|
||||
DB Navigator use — is the item filter below, issue #74.) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.INSERT" />
|
||||
<action android:name="android.intent.action.EDIT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="vnd.android.cursor.dir/event" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Create or edit an event another app/assistant/widget points at,
|
||||
addressed by the provider's *item* MIME type. Three actions share
|
||||
this filter, told apart at runtime by the intent's data:
|
||||
|
||||
• ACTION_INSERT — create. This is the form the Android docs'
|
||||
"insert an event" example and many apps use
|
||||
(setType("vnd.android.cursor.item/event")), e.g. DB Navigator's
|
||||
"Save to calendar". The dir-typed filter above alone missed it,
|
||||
so Calendula never showed in the chooser — and, when it was the
|
||||
only calendar app, the intent resolved to nothing (issue #74).
|
||||
• ACTION_INSERT_OR_EDIT — the third "add to calendar" action AOSP
|
||||
and Google Calendar register; a create, or an edit when it
|
||||
carries an event id.
|
||||
• ACTION_EDIT — edit the existing event at
|
||||
content://com.android.calendar/events/<id> (an id-less EDIT is a
|
||||
create). Opens the occurrence in the edit form, not the
|
||||
read-only detail — that's the VIEW filter above.
|
||||
|
||||
Create fields ride as CalendarContract extras; an edit's
|
||||
occurrence times ride as EXTRA_EVENT_BEGIN_TIME /
|
||||
EXTRA_EVENT_END_TIME when supplied (MainActivity.insertFormOrNull
|
||||
/ editEventKeyOrNull). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.INSERT" />
|
||||
<action android:name="android.intent.action.INSERT_OR_EDIT" />
|
||||
<action android:name="android.intent.action.EDIT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Open an existing event another app/widget points at (e.g. tapping
|
||||
an event in the Todo Agenda widget): ACTION_VIEW on
|
||||
content://com.android.calendar/events/<id>, the way AOSP fires it.
|
||||
Matched by the provider's item MIME type, not the path — a
|
||||
content: VIEW intent carries the resolved type
|
||||
(vnd.android.cursor.item/event) and a path-only filter wouldn't
|
||||
match it. The occurrence's times ride as EXTRA_EVENT_BEGIN_TIME /
|
||||
EXTRA_EVENT_END_TIME when the launcher supplies them
|
||||
(MainActivity.viewEventKeyOrNull, issue #48). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<!-- Launcher entry for MainActivity, split into two aliases so the app's
|
||||
launcher name can be switched at runtime between "Calendula" and
|
||||
"Calendar" (issue #44). Exactly one is enabled at a time; the app
|
||||
flips them via PackageManager.setComponentEnabledSetting
|
||||
(LauncherNameManager). The shortcuts meta-data lives here, not on
|
||||
MainActivity, because static shortcuts are published by whichever
|
||||
component owns the MAIN/LAUNCHER filter. -->
|
||||
<activity-alias
|
||||
android:name=".DefaultNameAlias"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:targetActivity=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
</activity-alias>
|
||||
<activity-alias
|
||||
android:name=".CalendarNameAlias"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name_calendar_alias"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:targetActivity=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity-alias>
|
||||
|
||||
<!-- Standalone surface for a captured crash report. MainActivity routes
|
||||
here on a startup crash-loop, so it stays clear of the app's Hilt
|
||||
@@ -77,20 +264,46 @@
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTask" />
|
||||
|
||||
<!-- 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. -->
|
||||
<!-- Quick Settings tile: a one-tap "New event" shortcut in the QS panel.
|
||||
Exported with BIND_QUICK_SETTINGS_TILE so only the system QS host can
|
||||
bind it; the action mirrors the launcher "New event" shortcut. -->
|
||||
<service
|
||||
android:name=".qs.NewEventTileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_qs_new_event"
|
||||
android:label="@string/qs_tile_new_event_label"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Snooze / dismiss actions on a reminder notification, plus the snooze
|
||||
re-show alarm. Not exported: only our own notification buttons and
|
||||
AlarmManager PendingIntents target it. -->
|
||||
<receiver
|
||||
android:name=".data.reminders.ReminderActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||
<receiver
|
||||
android:name=".widget.agenda.AgendaWidgetReceiver"
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
package de.jeanlucmakiola.calendula
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
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.floret.crash.CrashConfig
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Application entry point. Registered as android:name=".CalendulaApp"
|
||||
@@ -14,7 +28,83 @@ class CalendulaApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Install first thing so startup crashes are captured too (privacy-
|
||||
// respecting, on-device; the user submits the report by hand).
|
||||
CrashReporter.install(this)
|
||||
// respecting, on-device; the user submits the report by hand). The
|
||||
// capture/loop-detection/report machinery lives in floret-kit's
|
||||
// core-crash; only the app label + issue-tracker URLs are app-specific.
|
||||
CrashReporter.install(
|
||||
this,
|
||||
CrashConfig(
|
||||
appLabel = getString(R.string.app_name),
|
||||
newIssueUrl = getString(R.string.report_issue_url),
|
||||
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
||||
issueTitle = getString(R.string.crash_report_issue_title),
|
||||
),
|
||||
)
|
||||
reconcileAutoBackup()
|
||||
reconcileSpecialDates()
|
||||
reconcileCalendarVisibility()
|
||||
startReminderDelivery()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring reminder delivery up with the process (#75): a scan re-arms whatever
|
||||
* the system dropped and posts what a missed alarm still owes, then the
|
||||
* provider watch keeps edits re-planned. The daily worker is the backstop.
|
||||
*/
|
||||
private fun startReminderDelivery() {
|
||||
val deps = EntryPointAccessors.fromApplication(
|
||||
this, ReminderMaintenanceWorker.Deps::class.java,
|
||||
)
|
||||
val scanner = deps.reminderScanner()
|
||||
scanner.startWatchingProvider()
|
||||
scanner.scanInBackground()
|
||||
ReminderMaintenanceScheduler.apply(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any calendar switch-off not yet written to `Calendars.VISIBLE`,
|
||||
* including the set inherited from the retired app-local model (#75). A
|
||||
* no-op in the steady state; `RootScreen` re-runs it after a later grant.
|
||||
*/
|
||||
private fun reconcileCalendarVisibility() {
|
||||
val deps = EntryPointAccessors.fromApplication(
|
||||
this, CalendarVisibilityReconciler.Deps::class.java,
|
||||
)
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
deps.calendarVisibilityReconciler().run()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the scheduled auto-backup work back in line with the saved settings
|
||||
* on every launch — re-arms it after a reinstall and, crucially, cancels any
|
||||
* orphaned work once backup has been turned off.
|
||||
*/
|
||||
private fun reconcileAutoBackup() {
|
||||
val deps = EntryPointAccessors.fromApplication(this, BackupWorker.Deps::class.java)
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
val prefs = deps.settingsPrefs()
|
||||
BackupScheduler.apply(
|
||||
context = this@CalendulaApp,
|
||||
enabled = prefs.autoBackupEnabled.first(),
|
||||
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
|
||||
hasFolder = prefs.autoBackupFolderUri.first() != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-arm (or cancel) the daily special-dates reconcile from the saved
|
||||
* 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)
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
SpecialDatesScheduler.apply(
|
||||
context = this@CalendulaApp,
|
||||
enabled = deps.settingsPrefs().specialDatesEnabled.first(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,36 +2,71 @@ 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
|
||||
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
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.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.ui.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
||||
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** 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() {
|
||||
|
||||
// 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.
|
||||
@@ -45,6 +80,16 @@ class MainActivity : AppCompatActivity() {
|
||||
// by CalendarHost's import flow.
|
||||
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
||||
|
||||
// A prefilled new-event form from an external ACTION_INSERT launch (another
|
||||
// app/widget asking us to create an event, issue #30). Consumed once by
|
||||
// CalendarHost, which opens it in the create form for review.
|
||||
private var requestedInsert by mutableStateOf<InsertRequest?>(null)
|
||||
|
||||
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
||||
// opens the occurrence in the edit form. Same occurrence-key shape as the
|
||||
// detail channel; consumed once by CalendarHost.
|
||||
private var requestedEditKey by mutableStateOf<LongArray?>(null)
|
||||
|
||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||
// over the calendar on the next launch (the single-crash path). A startup
|
||||
// crash-loop is handled out of band, before setContent — see below.
|
||||
@@ -65,10 +110,14 @@ class MainActivity : AppCompatActivity() {
|
||||
return
|
||||
}
|
||||
|
||||
enableEdgeToEdge()
|
||||
requestedDetailKey = intent.detailKeyOrNull()
|
||||
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||
Configuration.UI_MODE_NIGHT_YES
|
||||
applyEdgeToEdge()
|
||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||
requestedNav = intent.navRequestOrNull()
|
||||
requestedImportUri = intent.importUriOrNull()
|
||||
requestedInsert = intent.insertRequestOrNull()
|
||||
requestedEditKey = intent.editEventKeyOrNull()
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
setContent {
|
||||
// One activity-scoped SettingsViewModel drives both the theme here
|
||||
@@ -80,9 +129,51 @@ 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.
|
||||
val context = LocalContext.current
|
||||
val use24Hour = remember(settings.timeFormat, context) {
|
||||
settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
}
|
||||
// The user's custom-font choice, resolved to a Material typography
|
||||
// (issue #19). Recomputed only when a token — or the custom-font
|
||||
// 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,
|
||||
onPersist = settingsViewModel::setTimelineScale,
|
||||
)
|
||||
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
||||
val typography = remember(fonts, context) {
|
||||
calendulaTypography(
|
||||
brand = resolveFontFamily(fonts.brand, FontRole.BRAND, context),
|
||||
plain = resolveFontFamily(fonts.plain, FontRole.PLAIN, context),
|
||||
)
|
||||
}
|
||||
CalendulaTheme(
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = settings.dynamicColor,
|
||||
typography = typography,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CompositionLocalProvider(
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalShowHourLines provides settings.showHourLines,
|
||||
LocalTimelineZoom provides timelineZoom,
|
||||
LocalSoftenColors provides settings.softenColors,
|
||||
) {
|
||||
RootScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -92,7 +183,17 @@ class MainActivity : AppCompatActivity() {
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
requestedInsertForm = requestedInsert?.form,
|
||||
requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
|
||||
onInsertConsumed = { requestedInsert = null },
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = { requestedEditKey = null },
|
||||
)
|
||||
}
|
||||
// A persistent corner marker so a debug build is never
|
||||
// mistaken for the production app; compiled out of release.
|
||||
if (BuildConfig.DEBUG) DebugRibbon()
|
||||
}
|
||||
pendingCrashReport?.let { report ->
|
||||
CrashReportDialog(
|
||||
report = report,
|
||||
@@ -113,6 +214,19 @@ 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 onResume() {
|
||||
super.onResume()
|
||||
// Reaching a running UI means startup succeeded; reset the loop trail.
|
||||
@@ -121,9 +235,11 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||
intent.insertRequestOrNull()?.let { requestedInsert = it }
|
||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,21 +253,110 @@ class MainActivity : AppCompatActivity() {
|
||||
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
else -> null
|
||||
} ?: return null
|
||||
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
|
||||
// it's a navigation, not a file to import, so [navRequestOrNull] owns it.
|
||||
if (uri.host == CALENDAR_PROVIDER_HOST) return null
|
||||
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||
}
|
||||
|
||||
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
|
||||
/**
|
||||
* A prefilled new-event form from an external launch asking us to create an
|
||||
* event — another app or widget (e.g. Todo Agenda) firing `ACTION_INSERT`
|
||||
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
|
||||
* event", i.e. create). The new event's fields ride as CalendarContract
|
||||
* extras; anything omitted falls back to the in-app "new event" defaults in
|
||||
* [buildInsertEventForm]. An intent that names no end time leaves the length
|
||||
* to the default-duration setting ([ImportSource.InsertOpenEnded], #54).
|
||||
*/
|
||||
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.
|
||||
val isCreate = action == Intent.ACTION_INSERT ||
|
||||
((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) &&
|
||||
editEventKeyOrNull() == null)
|
||||
if (!isCreate) return null
|
||||
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),
|
||||
location = getStringExtra(CalendarContract.Events.EVENT_LOCATION),
|
||||
rrule = getStringExtra(CalendarContract.Events.RRULE),
|
||||
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. */
|
||||
private fun Intent.longExtraOrNull(key: String): Long? =
|
||||
if (hasExtra(key)) getLongExtra(key, 0L) else null
|
||||
|
||||
/**
|
||||
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
|
||||
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
|
||||
* <epochMillis>`. Null for any other intent. The matching manifest filter is
|
||||
* what lets users pick Calendula from the system calendar chooser (issue #9).
|
||||
*/
|
||||
private fun Intent.calendarTimeDateOrNull(): LocalDate? {
|
||||
if (action != Intent.ACTION_VIEW) return null
|
||||
val uri = data ?: return null
|
||||
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||
val segments = uri.pathSegments
|
||||
if (segments.firstOrNull() != "time") return null
|
||||
val millis = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||
return Instant.fromEpochMilliseconds(millis)
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
}
|
||||
|
||||
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
|
||||
// An external date tap (launcher/clock) has no widget source, so it opens
|
||||
// the day view rooted over the default home view (OpenDate source = null).
|
||||
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
|
||||
// A widget header tap: open a top-level view with no date drill-in. The
|
||||
// empty string carried by [openViewIntent] means "the default home view".
|
||||
if (hasExtra(EXTRA_OPEN_VIEW)) {
|
||||
val name = getStringExtra(EXTRA_OPEN_VIEW).orEmpty()
|
||||
return WidgetNavRequest.OpenView(CalendarView.entries.firstOrNull { it.name == name })
|
||||
}
|
||||
val source = sourceViewOrNull()
|
||||
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||
return when {
|
||||
// Launcher long-press "New event" shortcut. Static shortcut intents
|
||||
// can't carry typed extras, so the action alone signals create-on-today.
|
||||
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
|
||||
getBooleanExtra(EXTRA_CREATE, false) ->
|
||||
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
|
||||
getStringExtra(EXTRA_DATE_ISO) != null ->
|
||||
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!)
|
||||
// A widget event tap carries both the occurrence and its source view;
|
||||
// reminders (no source) fall through to [detailKeyOrNull] instead.
|
||||
source != null && eventId != -1L -> WidgetNavRequest.OpenEvent(
|
||||
eventId = eventId,
|
||||
beginMillis = getLongExtra(EXTRA_BEGIN_MILLIS, 0L),
|
||||
endMillis = getLongExtra(EXTRA_END_MILLIS, 0L),
|
||||
source = source,
|
||||
)
|
||||
source != null && getStringExtra(EXTRA_DATE_ISO) != null ->
|
||||
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!, source)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** The view the launching widget represents, if any (see [EXTRA_SOURCE_VIEW]). */
|
||||
private fun Intent.sourceViewOrNull(): CalendarView? =
|
||||
getStringExtra(EXTRA_SOURCE_VIEW)
|
||||
?.let { name -> CalendarView.entries.firstOrNull { it.name == name } }
|
||||
|
||||
private fun Intent.detailKeyOrNull(): LongArray? {
|
||||
// A widget event tap (source present) is routed through [navRequestOrNull]
|
||||
// so it can also set the base view; only sourceless reminder taps land here.
|
||||
if (sourceViewOrNull() != null) return null
|
||||
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||
if (eventId == -1L) return null
|
||||
return longArrayOf(
|
||||
@@ -161,13 +366,75 @@ class MainActivity : AppCompatActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The detail key for an external "open this event" — ACTION_VIEW on
|
||||
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
|
||||
* tapping an existing event in the Todo Agenda widget, issue #48). Reuses the
|
||||
* same occurrence-key channel as reminder taps. The launcher passes the
|
||||
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME` when
|
||||
* it has them; a bare URI omits them, so we carry [NO_OCCURRENCE_TIME] and
|
||||
* [EventDetailViewModel] falls back to the event row's own DTSTART/DTEND
|
||||
* rather than rendering at the epoch.
|
||||
*/
|
||||
private fun Intent.viewEventKeyOrNull(): LongArray? {
|
||||
if (action != Intent.ACTION_VIEW) return null
|
||||
val uri = data ?: return null
|
||||
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||
val segments = uri.pathSegments
|
||||
if (segments.firstOrNull() != "events") return null
|
||||
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||
return longArrayOf(
|
||||
eventId,
|
||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
|
||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The occurrence key for an external "edit this event" — `ACTION_EDIT` on
|
||||
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
|
||||
* an assistant, task app, or widget that wants to open the event for editing
|
||||
* rather than viewing). Opens it in the edit form. Reuses the same
|
||||
* occurrence-key channel as reminder/view taps; the caller passes the
|
||||
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME`
|
||||
* when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and
|
||||
* [EventEditViewModel.openForEdit] falls back to the event row's own
|
||||
* DTSTART/DTEND. An id-less `ACTION_EDIT` — or `ACTION_INSERT_OR_EDIT`, which
|
||||
* some apps fire — is a create instead ([insertFormOrNull]).
|
||||
*/
|
||||
private fun Intent.editEventKeyOrNull(): LongArray? {
|
||||
if (action != Intent.ACTION_EDIT && action != Intent.ACTION_INSERT_OR_EDIT) return null
|
||||
val uri = data ?: return null
|
||||
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||
val segments = uri.pathSegments
|
||||
if (segments.firstOrNull() != "events") return null
|
||||
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||
return longArrayOf(
|
||||
eventId,
|
||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
|
||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// The calendar provider's authority/host. A date tap arrives as
|
||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
|
||||
|
||||
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
||||
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
||||
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
||||
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
||||
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
||||
|
||||
// A widget header tap asking to open a top-level view (no date drill-in).
|
||||
// Its value is the target [CalendarView] name, or "" for the default view.
|
||||
private const val EXTRA_OPEN_VIEW = "de.jeanlucmakiola.calendula.extra.OPEN_VIEW"
|
||||
|
||||
// The [CalendarView] (by name) of the widget a launch came from. Roots the
|
||||
// in-app back stack in that view; absent for non-widget launches (reminders).
|
||||
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
|
||||
|
||||
// Fired by the launcher long-press "New event" shortcut (res/xml/
|
||||
// shortcuts.xml hardcodes this string — keep the two in sync).
|
||||
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
|
||||
@@ -190,14 +457,35 @@ class MainActivity : AppCompatActivity() {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
/** Open the day view anchored on [date] (home-screen widgets). */
|
||||
fun openDateIntent(context: Context, date: LocalDate): Intent =
|
||||
/**
|
||||
* Open the day view anchored on [date], drilled in over the launching
|
||||
* widget's [source] view (home-screen widgets). Backing out of the day
|
||||
* returns to [source], then to the default home view.
|
||||
*/
|
||||
fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
data = "calendula://date/$date".toUri()
|
||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one occurrence's detail from a widget event tap, rooting the back
|
||||
* stack in the widget's [source] view. Same occurrence-key shape as
|
||||
* [eventDetailIntent]; the [source] extra is what distinguishes a widget
|
||||
* tap (sets the base view) from a reminder tap (leaves it untouched).
|
||||
*/
|
||||
fun openEventIntent(
|
||||
context: Context,
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
endMillis: Long,
|
||||
source: CalendarView,
|
||||
): Intent = eventDetailIntent(context, eventId, beginMillis, endMillis).apply {
|
||||
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
||||
}
|
||||
|
||||
/** Open the create-event form prefilled for [date] (home-screen widgets). */
|
||||
fun openCreateIntent(context: Context, date: LocalDate): Intent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
@@ -206,5 +494,19 @@ class MainActivity : AppCompatActivity() {
|
||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the app on a top-level [view] with no date drill-in — a widget
|
||||
* header tap. A null [view] opens the user's default home view (the agenda
|
||||
* widget's "Upcoming" title); a concrete view roots there over the default
|
||||
* home (the month widget's month/year title → [CalendarView.Month]). The
|
||||
* per-view data URI keeps distinct headers' PendingIntents from collapsing.
|
||||
*/
|
||||
fun openViewIntent(context: Context, view: CalendarView?): Intent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
data = "calendula://view/${view?.name ?: "default"}".toUri()
|
||||
putExtra(EXTRA_OPEN_VIEW, view?.name ?: "")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package de.jeanlucmakiola.calendula.data.appname
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** The launcher label the app shows for itself (issue #44). */
|
||||
enum class LauncherName { CALENDULA, CALENDAR }
|
||||
|
||||
/** The two launcher aliases declared in the manifest. */
|
||||
enum class LauncherAlias { DEFAULT, CALENDAR }
|
||||
|
||||
/** One component-enable change in a [aliasWritePlan]. */
|
||||
data class AliasStateChange(val alias: LauncherAlias, val enabled: Boolean)
|
||||
|
||||
/**
|
||||
* Interpret the `CalendarNameAlias` component-enabled state as a [LauncherName].
|
||||
* Only [PackageManager.COMPONENT_ENABLED_STATE_ENABLED] means the "Calendar"
|
||||
* name is active; `DEFAULT` (never toggled) and `DISABLED` both resolve to the
|
||||
* manifest default, "Calendula".
|
||||
*/
|
||||
fun launcherNameFor(calendarAliasState: Int): LauncherName =
|
||||
if (calendarAliasState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
|
||||
LauncherName.CALENDAR
|
||||
} else {
|
||||
LauncherName.CALENDULA
|
||||
}
|
||||
|
||||
/**
|
||||
* The ordered enable/disable steps to switch the launcher name to [target].
|
||||
* Always **enables the target alias first, then disables the other** — the two
|
||||
* `setComponentEnabledSetting` calls are not atomic, and this ordering means the
|
||||
* only transient the launcher can observe is a harmless two-entry state, never a
|
||||
* zero-entry one (which would briefly drop the app from the launcher).
|
||||
*/
|
||||
fun aliasWritePlan(target: LauncherName): List<AliasStateChange> = when (target) {
|
||||
LauncherName.CALENDAR -> listOf(
|
||||
AliasStateChange(LauncherAlias.CALENDAR, enabled = true),
|
||||
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
|
||||
)
|
||||
LauncherName.CALENDULA -> listOf(
|
||||
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
|
||||
AliasStateChange(LauncherAlias.CALENDAR, enabled = false),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the app's launcher label between "Calendula" and "Calendar" by
|
||||
* enabling/disabling the two `<activity-alias>` components (issue #44). The
|
||||
* component-enabled state is the single source of truth — there is no persisted
|
||||
* preference — so [current] reads it straight from [PackageManager] and the two
|
||||
* always agree.
|
||||
*
|
||||
* The decision logic lives in the pure [launcherNameFor] / [aliasWritePlan]
|
||||
* functions above (JVM-tested); this class is only the thin framework seam.
|
||||
*/
|
||||
@Singleton
|
||||
class LauncherNameManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val packageManager: PackageManager get() = context.packageManager
|
||||
|
||||
// The class portion is namespace-qualified (suffix-free), the package portion
|
||||
// is the applicationId (which carries the .debug / .releasetest suffix). The
|
||||
// manifest's ".CalendarNameAlias" resolves its class against the namespace, so
|
||||
// the real component is <namespace>.CalendarNameAlias registered under the
|
||||
// suffixed applicationId. Do NOT use ComponentName(context, ".CalendarNameAlias")
|
||||
// — its leading-dot form prepends the applicationId to the class too, producing
|
||||
// "<appId>.CalendarNameAlias" and failing on every debug / releaseTest build.
|
||||
private fun component(alias: LauncherAlias): ComponentName {
|
||||
val simpleName = when (alias) {
|
||||
LauncherAlias.DEFAULT -> "DefaultNameAlias"
|
||||
LauncherAlias.CALENDAR -> "CalendarNameAlias"
|
||||
}
|
||||
return ComponentName(context.packageName, "$NAMESPACE.$simpleName")
|
||||
}
|
||||
|
||||
/** The launcher name currently in effect. */
|
||||
fun current(): LauncherName =
|
||||
launcherNameFor(packageManager.getComponentEnabledSetting(component(LauncherAlias.CALENDAR)))
|
||||
|
||||
/** Switch the launcher name to [name] (no-op cost if already active). */
|
||||
fun set(name: LauncherName) {
|
||||
for (change in aliasWritePlan(name)) {
|
||||
val state = if (change.enabled) {
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
} else {
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||
}
|
||||
packageManager.setComponentEnabledSetting(
|
||||
component(change.alias),
|
||||
state,
|
||||
PackageManager.DONT_KILL_APP,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* The app's namespace (R-class package) — NOT `applicationId`, which
|
||||
* carries the build-type suffix. Kept in sync with `namespace` in
|
||||
* `app/build.gradle.kts`.
|
||||
*/
|
||||
const val NAMESPACE = "de.jeanlucmakiola.calendula"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package de.jeanlucmakiola.calendula.data.backup
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
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 de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Schedules and runs the automatic periodic `.ics` backup of local calendars
|
||||
* (issue #8). One-way export only — it mirrors the manual "Back up" into a
|
||||
* user-chosen folder on an interval; it is not a sync. Stays INTERNET-free:
|
||||
* everything is local provider reads + a local file write.
|
||||
*/
|
||||
object BackupScheduler {
|
||||
|
||||
private const val WORK_NAME = "auto-backup"
|
||||
private const val WORK_NAME_NOW = "auto-backup-now"
|
||||
|
||||
/**
|
||||
* Reconcile the scheduled work with the current settings: enqueue a unique
|
||||
* periodic request when backup is on and a folder is set, otherwise cancel
|
||||
* it. WorkManager persists the request across reboots on its own.
|
||||
*
|
||||
* The first run is delayed by one interval so it never overlaps an immediate
|
||||
* [runNow] (two simultaneous writes would race and leave a "(1)" duplicate).
|
||||
*/
|
||||
fun apply(context: Context, enabled: Boolean, intervalMinutes: Long, hasFolder: Boolean) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
if (!enabled || !hasFolder) {
|
||||
workManager.cancelUniqueWork(WORK_NAME)
|
||||
// Also cancel any pending immediate run — otherwise a failing run-now
|
||||
// keeps retrying after the user has turned backup off.
|
||||
workManager.cancelUniqueWork(WORK_NAME_NOW)
|
||||
return
|
||||
}
|
||||
// WorkManager's own floor is 15 min; the UI floors the user choice at 30.
|
||||
val interval = intervalMinutes.coerceAtLeast(15L)
|
||||
val request = PeriodicWorkRequestBuilder<BackupWorker>(interval, TimeUnit.MINUTES)
|
||||
.setInitialDelay(interval, TimeUnit.MINUTES)
|
||||
.build()
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one export immediately (e.g. right after enabling or changing the
|
||||
* folder) for instant feedback. Unique + REPLACE so rapid taps coalesce into
|
||||
* a single run rather than racing each other.
|
||||
*/
|
||||
fun runNow(context: Context) {
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
WORK_NAME_NOW,
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
OneTimeWorkRequestBuilder<BackupWorker>().build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the local calendars to `calendula-backup.ics` in the configured folder,
|
||||
* overwriting the previous file. Pulls its collaborators through a Hilt
|
||||
* [EntryPoint] so it works under WorkManager's default (no-arg) worker factory —
|
||||
* no custom factory / Application wiring needed. Records the outcome for the
|
||||
* settings status line, and notifies after repeated failures.
|
||||
*/
|
||||
class BackupWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Deps {
|
||||
fun settingsPrefs(): SettingsPrefs
|
||||
fun repository(): CalendarRepository
|
||||
fun exporter(): IcsExporter
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||
val prefs = deps.settingsPrefs()
|
||||
// Respect the toggle even for already-queued work: if backup was turned
|
||||
// off, no-op (and don't retry) so a lingering run can't revive itself.
|
||||
if (!prefs.autoBackupEnabled.first()) return Result.success()
|
||||
val folder = prefs.autoBackupFolderUri.first()
|
||||
?: return Result.failure() // nothing to write to — leave scheduling to settings
|
||||
val now = System.currentTimeMillis()
|
||||
return try {
|
||||
val events = deps.repository().exportEvents()
|
||||
val content = IcsWriter().writeCalendar(events, Clock.System.now())
|
||||
deps.exporter().writeToFolder(folder.toUri(), BACKUP_FILE_NAME, content)
|
||||
prefs.recordAutoBackupRun(success = true, atMillis = now)
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
prefs.recordAutoBackupRun(success = false, atMillis = now)
|
||||
if (prefs.autoBackupStatus.first().consecutiveFailures >= FAILURE_NOTIFY_THRESHOLD) {
|
||||
notifyFailure(applicationContext)
|
||||
}
|
||||
Log.w(TAG, "Automatic backup failed", e)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyFailure(context: Context) {
|
||||
val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!canPost) return
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.backup_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply { description = context.getString(R.string.backup_channel_description) },
|
||||
)
|
||||
val launch = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
val tap = launch?.let {
|
||||
PendingIntent.getActivity(
|
||||
context, 0, it,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(context.getString(R.string.backup_failed_title))
|
||||
.setContentText(context.getString(R.string.backup_failed_text))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setAutoCancel(true)
|
||||
.apply { tap?.let(::setContentIntent) }
|
||||
.build()
|
||||
try {
|
||||
manager.notify(NOTIFICATION_ID, notification)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Could not post backup-failure notification", e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BACKUP_FILE_NAME = "calendula-backup.ics"
|
||||
private const val FAILURE_NOTIFY_THRESHOLD = 2
|
||||
private const val CHANNEL_ID = "backup"
|
||||
private const val NOTIFICATION_ID = 2
|
||||
private const val TAG = "BackupWorker"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -50,21 +49,36 @@ internal fun toProviderAllDayMinutes(
|
||||
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* The next date on or after [today] carrying [month]/[day] — the date to sample
|
||||
* a yearly all-day reminder's UTC offset at. A managed birthday's DTSTART is an
|
||||
* ancient anchor (1972 for year-less dates), a year whose timezone rules
|
||||
* (pre-DST offsets) differ from today's; sampling the offset there skews every
|
||||
* modern occurrence by hours. Sampling at the upcoming occurrence makes the
|
||||
* stored offset correct for it and its neighbours (only ±1h DST drift remains,
|
||||
* the inherent limit). Feb-29 skips forward to the next leap year.
|
||||
*/
|
||||
internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): LocalDate {
|
||||
var year = today.year
|
||||
// A Feb-29 date is valid only every ~4 years; 8 tries always reaches one.
|
||||
repeat(8) {
|
||||
val candidate = runCatching { LocalDate.of(year, month, day) }.getOrNull()
|
||||
if (candidate != null && !candidate.isBefore(today)) return candidate
|
||||
year++
|
||||
}
|
||||
// Unreachable for real month/day inputs; fall back to the raw anchor.
|
||||
return LocalDate.of(today.year, month, day)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the semantic whole-day lead time from a raw all-day reminder
|
||||
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
|
||||
* returns the right day count regardless of which [timeOfDayMinutes] wrote the
|
||||
* row — including pre-feature rows (raw multiples of 1440, fired at UTC midnight)
|
||||
* and rows written under a different timezone. A negative [rawMinutes] (fire
|
||||
* after DTSTART) folds to day 0.
|
||||
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
|
||||
* detail screen. Delegates to [allDayLeadDays], so the day displayed is the day
|
||||
* the reminder actually fires on.
|
||||
*/
|
||||
internal fun fromProviderAllDayMinutes(
|
||||
rawMinutes: Int,
|
||||
startDate: LocalDate,
|
||||
zone: ZoneId,
|
||||
): Int {
|
||||
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
val fireLocalDate = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE)
|
||||
.atZone(zone).toLocalDate()
|
||||
return ChronoUnit.DAYS.between(fireLocalDate, startDate).toInt() * MINUTES_PER_DAY
|
||||
}
|
||||
timeOfDayMinutes: Int,
|
||||
): Int = allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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.
|
||||
*/
|
||||
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()
|
||||
|
||||
/** The full palette, in swatch-row order. */
|
||||
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,5 +24,15 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
||||
} else {
|
||||
null
|
||||
},
|
||||
// A special-dates mirror calendar, recognised by its durable CAL_SYNC2
|
||||
// marker (only meaningful on the local calendars the app owns). This is
|
||||
// the source of truth for the editor lock — independent of any stored
|
||||
// preference that a backup restore could have wiped.
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ interface CalendarRepository {
|
||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||
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]).
|
||||
*/
|
||||
suspend fun searchEvents(query: String): List<EventInstance>
|
||||
|
||||
/**
|
||||
* The event-colour palette a calendar's account publishes; empty when it
|
||||
* exposes none (see [CalendarDataSource.eventColorPalette]).
|
||||
@@ -31,11 +38,22 @@ interface CalendarRepository {
|
||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||
suspend fun deleteCalendar(id: Long)
|
||||
|
||||
/**
|
||||
* Show or hide [ids] device-wide (`Calendars.VISIBLE`), which also gates
|
||||
* their reminders — see [CalendarDataSource.setCalendarVisible]. Written one
|
||||
* at a time, in order; a failure part-way leaves the earlier writes standing.
|
||||
*
|
||||
* Without `WRITE_CALENDAR` the choice is kept app-side instead (see
|
||||
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds]).
|
||||
*/
|
||||
suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean)
|
||||
|
||||
/**
|
||||
* Every event of the writable local calendars, ready to serialise into a
|
||||
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
||||
* [calendarIds] narrows the export to a chosen subset; `null` exports all.
|
||||
*/
|
||||
suspend fun exportEvents(): List<IcsEvent>
|
||||
suspend fun exportEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
|
||||
|
||||
/**
|
||||
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
|
||||
@@ -53,6 +71,20 @@ interface CalendarRepository {
|
||||
*/
|
||||
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
||||
|
||||
/**
|
||||
* Move an event (recurring: the whole series, with its exceptions) to
|
||||
* [targetCalendarId] and apply the [original]→[updated] field edits; returns
|
||||
* the new event's `Events._ID`. Copy+delete under the hood
|
||||
* (see [CalendarDataSource.moveEvent]) — `CALENDAR_ID` can't be updated in
|
||||
* place.
|
||||
*/
|
||||
suspend fun moveEvent(
|
||||
eventId: Long,
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long
|
||||
|
||||
/**
|
||||
* Change a single occurrence of a recurring event (exception row with the
|
||||
* form's values); returns the exception's `Events._ID`.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
@@ -14,11 +15,18 @@ import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -45,39 +53,122 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
extraBufferCapacity = 1,
|
||||
)
|
||||
|
||||
/**
|
||||
* Bumped on every provider notification, so one tick's calendar read can be
|
||||
* shared by everything that needs it (see [calendarsSnapshot]).
|
||||
*/
|
||||
private val generation = AtomicLong(0L)
|
||||
|
||||
init {
|
||||
dataSource.registerChangeListener { ticks.tryEmit(Unit) }
|
||||
dataSource.registerChangeListener {
|
||||
generation.incrementAndGet()
|
||||
ticks.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>> =
|
||||
ticks
|
||||
.onStart { emit(Unit) }
|
||||
.reQuery { dataSource.calendars() }
|
||||
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
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.flowOn(io)
|
||||
|
||||
// Instances are filtered by the app-side hidden-calendar set (M3): an event
|
||||
// is dropped whenever the user has hidden its calendar. Re-runs when the
|
||||
// provider ticks *or* the hidden set changes — toggling a calendar in the
|
||||
// filter sheet updates every view immediately. [calendars] stays unfiltered
|
||||
// so the filter sheet can list and re-enable hidden calendars.
|
||||
// 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(
|
||||
ticks
|
||||
.onStart { emit(Unit) }
|
||||
.reQuery {
|
||||
dataSource.instances(
|
||||
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,
|
||||
) { instances, hidden ->
|
||||
if (hidden.isEmpty()) instances
|
||||
else instances.filterNot { it.calendarId in hidden }
|
||||
}.flowOn(io)
|
||||
) { queried, hidden ->
|
||||
val excluded = hidden + queried.switchedOffCalendarIds
|
||||
if (excluded.isEmpty()) queried.instances
|
||||
else queried.instances.filterNot { it.calendarId in excluded }
|
||||
}
|
||||
// Any DataStore edit re-emits the hidden set even when unchanged;
|
||||
// collapse those so views don't re-render for them.
|
||||
.distinctUntilChanged()
|
||||
.flowOn(io)
|
||||
|
||||
/** One instances query plus the visibility it must be filtered against. */
|
||||
private data class QueriedInstances(
|
||||
val instances: List<EventInstance>,
|
||||
val switchedOffCalendarIds: Set<Long>,
|
||||
)
|
||||
|
||||
/** Calendars switched off at system level — hidden, and never reminded about. */
|
||||
private suspend fun invisibleCalendarIds(): Set<Long> = calendarsSnapshot()
|
||||
.filterNot { it.isVisibleInSystem }
|
||||
.mapTo(mutableSetOf()) { it.id }
|
||||
|
||||
private val calendarsLock = Mutex()
|
||||
private var cachedGeneration = -1L
|
||||
private var cachedPending: Set<Long>? = null
|
||||
private var cachedCalendars: List<CalendarSource> = emptyList()
|
||||
|
||||
/**
|
||||
* The calendar list for the current tick, queried once and shared, so every
|
||||
* open view doesn't pay for its own `Calendars` query and all of them see
|
||||
* one snapshot.
|
||||
*
|
||||
* An empty result is never cached — that is what a read without the calendar
|
||||
* permission returns, and the grant itself doesn't notify the provider. The
|
||||
* pending switch-off set keys the cache alongside the tick, because an id
|
||||
* leaves it before the invalidating observer is dispatched.
|
||||
*/
|
||||
private suspend fun calendarsSnapshot(): List<CalendarSource> = calendarsLock.withLock {
|
||||
val current = generation.get()
|
||||
val pending = prefs.pendingDisabledCalendarIds.first()
|
||||
if (current != cachedGeneration || pending != cachedPending || cachedCalendars.isEmpty()) {
|
||||
cachedCalendars = dataSource.calendars()
|
||||
cachedGeneration = current
|
||||
cachedPending = pending
|
||||
}
|
||||
cachedCalendars
|
||||
}
|
||||
|
||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||
dataSource.eventDetail(eventId, allDayReminderTimeMinutes())
|
||||
?: throw NoSuchEventException(eventId)
|
||||
}
|
||||
|
||||
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
||||
if (query.isBlank()) return@withContext emptyList()
|
||||
val excluded = prefs.hiddenCalendarIds.first() +
|
||||
prefs.pendingDisabledCalendarIds.first() +
|
||||
invisibleCalendarIds()
|
||||
dataSource.searchEvents(query)
|
||||
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
||||
}
|
||||
|
||||
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
@@ -101,7 +192,24 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
override suspend fun deleteCalendar(id: Long) =
|
||||
withContext(io) { dataSource.deleteCalendar(id) }
|
||||
|
||||
override suspend fun exportEvents() = withContext(io) { dataSource.exportableEvents() }
|
||||
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
|
||||
withContext(io) {
|
||||
if (dataSource.canWriteCalendars()) {
|
||||
ids.forEach { dataSource.setCalendarVisible(it, visible) }
|
||||
// Nothing of ours is left waiting for the provider once the
|
||||
// write lands (and switching one back on retires its entry).
|
||||
prefs.removePendingDisabledCalendarIds(ids)
|
||||
} else if (visible) {
|
||||
prefs.removePendingDisabledCalendarIds(ids)
|
||||
} else {
|
||||
// Read-only permission: the switch still works, app-side, and
|
||||
// the reconciler flushes it if WRITE_CALENDAR ever arrives.
|
||||
prefs.addPendingDisabledCalendarIds(ids)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
||||
withContext(io) { dataSource.exportableEvents(calendarIds) }
|
||||
|
||||
override suspend fun importEvents(
|
||||
targetCalendarId: Long,
|
||||
@@ -139,6 +247,17 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
dataSource.deleteEvent(eventId)
|
||||
}
|
||||
|
||||
override suspend fun moveEvent(
|
||||
eventId: Long,
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long = withContext(io) {
|
||||
dataSource.moveEvent(
|
||||
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun updateOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.calendarVisibilityPlan
|
||||
import de.jeanlucmakiola.calendula.domain.hasSystemHiddenCalendars
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Keeps [CalendarPrefs.pendingDisabledCalendarIds] and the system's
|
||||
* `Calendars.VISIBLE` in step (#75): the fold-in of the retired app-local
|
||||
* visibility model, and the standing drain for switch-offs made without
|
||||
* `WRITE_CALENDAR`.
|
||||
*
|
||||
* Runs on every launch and on every calendar-permission grant; a no-op once the
|
||||
* pending set is empty and the notice is settled. Only ever hides (see
|
||||
* [calendarVisibilityPlan]); on an upgraded install the first run that sees a
|
||||
* system-hidden calendar arms the one-time explanatory notice.
|
||||
*/
|
||||
@Singleton
|
||||
class CalendarVisibilityReconciler @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val dataSource: CalendarDataSource,
|
||||
private val prefs: CalendarPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
suspend fun run() = withContext(io) {
|
||||
// The DataStore reads sit inside the guard too: this runs in a bare
|
||||
// application-scope coroutine, so an IOException from a damaged
|
||||
// preferences file would take the process down on every launch.
|
||||
try {
|
||||
// Settled ahead of the permission gate, so an update installed
|
||||
// before the first grant can't later look like an upgrade.
|
||||
if (!isUpgradeInstall()) settleNoticeOnce(pending = false)
|
||||
if (!hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
|
||||
val pending = prefs.pendingDisabledCalendarIds.first()
|
||||
val noticeSettled = prefs.visibilityNoticePending.first() != null
|
||||
if (pending.isEmpty() && noticeSettled) return@withContext
|
||||
val calendars = dataSource.calendars()
|
||||
// Empty means "couldn't read" (null cursor), not "no calendars".
|
||||
// Both decisions below are one-way, so leave them to the next run.
|
||||
if (calendars.isEmpty()) return@withContext
|
||||
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
|
||||
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
||||
return@withContext
|
||||
}
|
||||
val plan = calendarVisibilityPlan(calendars, pending)
|
||||
prefs.removePendingDisabledCalendarIds(plan.settled)
|
||||
// One calendar per write: the provider skips its reminder-alarm
|
||||
// reschedule for anything but a single-id update (see
|
||||
// [CalendarDataSource.setCalendarVisible]).
|
||||
for (id in plan.hide) {
|
||||
dataSource.setCalendarVisible(id, false)
|
||||
prefs.removePendingDisabledCalendarIds(setOf(id))
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Calendar visibility reconcile failed; will retry", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle the one-time notice: [pending] arms it, false retires it unshown.
|
||||
* Answered once and stored either way, so it can't resurface later.
|
||||
*/
|
||||
private suspend fun settleNoticeOnce(pending: Boolean) {
|
||||
if (prefs.visibilityNoticePending.first() != null) return
|
||||
prefs.setVisibilityNoticePending(pending)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this install has ever run an earlier version. The notice explains
|
||||
* a behaviour change, so a first install has nothing to announce.
|
||||
*/
|
||||
private fun isUpgradeInstall(): Boolean = try {
|
||||
@Suppress("DEPRECATION")
|
||||
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
info.lastUpdateTime > info.firstInstallTime
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
Log.w(TAG, "Own package info unavailable; treating as a fresh install", e)
|
||||
false
|
||||
}
|
||||
|
||||
private fun hasPermission(permission: String): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/** Lets non-injectable entry points (the Application) reach the reconciler. */
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Deps {
|
||||
fun calendarVisibilityReconciler(): CalendarVisibilityReconciler
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "CalendarVisibility"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
@@ -13,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
@@ -22,27 +24,34 @@ 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
|
||||
// negative — only an *absent* DTSTART marks a malformed row worth dropping.
|
||||
// Dropping negatives made every occurrence of such a series un-openable
|
||||
// (the detail loads the ancient series-master DTSTART), see issue #34.
|
||||
if (isNull(EventDetailProjection.IDX_DTSTART)) {
|
||||
Log.w(TAG, "Dropping event with missing dtstart")
|
||||
return null
|
||||
}
|
||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||
|
||||
if (begin < 0L) {
|
||||
Log.w(TAG, "Dropping event with negative dtstart=$begin")
|
||||
return null
|
||||
}
|
||||
|
||||
// Recurring events store DURATION instead of DTEND, so the series row's
|
||||
// DTEND is null. Keep the event (end == begin); callers that opened a
|
||||
// specific occurrence supply the real per-occurrence times from
|
||||
// CalendarContract.Instances. Only a present-but-backwards DTEND is malformed.
|
||||
// DTEND is null — derive the length from DURATION (as SearchMapper and
|
||||
// IcsExportMapper do). Callers that opened a specific occurrence overwrite
|
||||
// both times with the per-occurrence values from CalendarContract.Instances;
|
||||
// a caller that names no occurrence (a bare content://.../events/<id> VIEW
|
||||
// intent, issue #48) keeps this row's own times, so the length has to be
|
||||
// right here or the series renders zero-length. A present-but-backwards
|
||||
// DTEND is malformed, but dropping the row would make the event un-openable
|
||||
// — the same trap as the pre-1970 DTSTART bug above (issue #34): it would
|
||||
// surface as the generic error screen with no way to open the event and fix
|
||||
// it. Clamp to a zero-length event instead (matching SearchMapper).
|
||||
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||
begin
|
||||
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))
|
||||
} else {
|
||||
val rawEnd = getLong(EventDetailProjection.IDX_DTEND)
|
||||
if (rawEnd < begin) {
|
||||
Log.w(TAG, "Dropping event with dtend=$rawEnd < dtstart=$begin")
|
||||
return null
|
||||
}
|
||||
rawEnd
|
||||
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
||||
}
|
||||
|
||||
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
||||
@@ -81,7 +90,13 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
val displayReminders = if (isAllDay) {
|
||||
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
val zone = ZoneId.systemDefault()
|
||||
reminders.map { it.copy(minutes = fromProviderAllDayMinutes(it.minutes, startDate, zone)) }
|
||||
reminders.map {
|
||||
it.copy(
|
||||
minutes = fromProviderAllDayMinutes(
|
||||
it.minutes, startDate, zone, allDayReminderTimeMinutes,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
reminders
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toJavaLocalDateTime
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.LocalDateTime as JavaLocalDateTime
|
||||
|
||||
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
||||
internal data class EventWriteTimes(
|
||||
@@ -20,7 +22,11 @@ internal data class EventWriteTimes(
|
||||
/**
|
||||
* All-day events live at UTC midnights with an exclusive DTEND (the
|
||||
* CalendarContract convention — a one-day event ends at the next midnight);
|
||||
* timed events resolve their wall-clock values in [zone].
|
||||
* timed events resolve their wall-clock values in the form's own
|
||||
* [EventForm.timezone], falling back to [zone] (the device) when it doesn't pin
|
||||
* one. Passing the device zone is therefore still correct for an unpinned form —
|
||||
* but it no longer overrides a pinned event's zone, which is what used to
|
||||
* silently re-anchor a foreign-zone event to the device on any time edit.
|
||||
*/
|
||||
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
|
||||
EventWriteTimes(
|
||||
@@ -31,22 +37,92 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
||||
timezone = "UTC",
|
||||
)
|
||||
} else {
|
||||
val writeZone = writeZone(zone)
|
||||
EventWriteTimes(
|
||||
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
||||
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
||||
timezone = zone.id,
|
||||
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||
timezone = writeZone.id,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The zone this form's DTSTART is expressed in: UTC for an all-day event (the
|
||||
* provider's date anchor), otherwise the form's own pinned zone, falling back to
|
||||
* [deviceZone] when it doesn't pin one or pins something the tz database can't
|
||||
* parse.
|
||||
*/
|
||||
private fun EventForm.writeZone(deviceZone: ZoneId): ZoneId = if (isAllDay) {
|
||||
ZoneOffset.UTC
|
||||
} else {
|
||||
timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: deviceZone
|
||||
}
|
||||
|
||||
/**
|
||||
* The form's start as a bare wall-clock value — what the user sees on the form,
|
||||
* stripped of any zone. All-day events use their date's midnight rather than
|
||||
* [EventForm.start]'s placeholder time-of-day, which exists only so switching the
|
||||
* event back to timed has something to show.
|
||||
*/
|
||||
private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) {
|
||||
start.date.toJavaLocalDate().atStartOfDay()
|
||||
} else {
|
||||
start.toJavaLocalDateTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 2445 duration for a recurring event's row (the provider requires
|
||||
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
||||
* events, seconds otherwise.
|
||||
*/
|
||||
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) {
|
||||
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D"
|
||||
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String =
|
||||
rfc2445Duration(dtEndMillis - dtStartMillis, isAllDay)
|
||||
|
||||
/**
|
||||
* RFC 2445 duration for a [spanMillis]-long event: whole days for all-day
|
||||
* events (the provider's convention), seconds otherwise. Shared by the write
|
||||
* paths that need a DURATION but start from a raw millisecond span (the series
|
||||
* copy and exception replay of a calendar move) rather than [EventWriteTimes].
|
||||
*/
|
||||
internal fun rfc2445Duration(spanMillis: Long, isAllDay: Boolean): String = if (isAllDay) {
|
||||
"P${spanMillis / MILLIS_PER_DAY}D"
|
||||
} else {
|
||||
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
||||
"P${spanMillis / 1_000L}S"
|
||||
}
|
||||
|
||||
/**
|
||||
* Column values for a brand-new Events row: identity, times (the provider's
|
||||
* invariant — recurring rows carry RRULE+DURATION and no DTEND, one-off rows
|
||||
* carry DTEND), availability/access level and trimmed optional text. Shared by
|
||||
* the user-event and managed-event insert paths, which differ only in the
|
||||
* [uid] they stamp; colour and attendees are user-event concerns the caller
|
||||
* layers on top.
|
||||
*/
|
||||
internal fun buildEventInsertValues(
|
||||
form: EventForm,
|
||||
uid: String,
|
||||
times: EventWriteTimes,
|
||||
): Map<String, Any?> = buildMap {
|
||||
put(
|
||||
CalendarContract.Events.CALENDAR_ID,
|
||||
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
||||
)
|
||||
put(CalendarContract.Events.UID_2445, uid)
|
||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||
if (form.rrule == null) {
|
||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||
} else {
|
||||
put(CalendarContract.Events.RRULE, form.rrule)
|
||||
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||
}
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
form.location.trim().takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
form.description.trim().takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,10 +135,12 @@ internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (
|
||||
* Time fields travel together (the provider validates them as a unit):
|
||||
* - unchanged times, all-day flag and rrule → no time columns at all;
|
||||
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
||||
* - recurring result → the *series* DTSTART moves by the same delta the user
|
||||
* applied to the displayed occurrence ([seriesDtStartMillis] is the row's
|
||||
* current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps
|
||||
* past occurrences intact when someone edits a later occurrence's time.
|
||||
* - recurring result → the *series* DTSTART moves by the same **wall-clock**
|
||||
* shift the user applied to the displayed occurrence and is re-resolved in the
|
||||
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
|
||||
* replaces DTEND, RRULE is written. This keeps past occurrences intact when
|
||||
* someone edits a later occurrence's time, and keeps the anchor's time-of-day
|
||||
* stable across a DST boundary or a zone change between the two.
|
||||
*/
|
||||
internal fun buildEventUpdateValues(
|
||||
original: EventForm,
|
||||
@@ -89,10 +167,14 @@ internal fun buildEventUpdateValues(
|
||||
putAll(eventColorColumns(updated.colorKey, updated.color))
|
||||
}
|
||||
|
||||
// A zone change counts as a time change even when the wall-clock is
|
||||
// untouched: the same 09:00 in another zone is a different instant, so
|
||||
// DTSTART has to move with it.
|
||||
val timesChanged = updated.start != original.start ||
|
||||
updated.end != original.end ||
|
||||
updated.isAllDay != original.isAllDay ||
|
||||
updated.rrule != original.rrule
|
||||
updated.rrule != original.rrule ||
|
||||
updated.timezone != original.timezone
|
||||
if (!timesChanged) return@buildMap
|
||||
|
||||
val newTimes = updated.toWriteTimes(zone)
|
||||
@@ -104,8 +186,23 @@ internal fun buildEventUpdateValues(
|
||||
put(CalendarContract.Events.RRULE, null)
|
||||
put(CalendarContract.Events.DURATION, null)
|
||||
} else {
|
||||
val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis
|
||||
put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta)
|
||||
// Move the series anchor by the *wall-clock* shift the user applied to the
|
||||
// displayed occurrence, then re-resolve it in the event's (possibly new)
|
||||
// zone — never by a millisecond delta. An instant delta silently bakes in
|
||||
// the offset that happened to apply on the edited occurrence's date, which
|
||||
// is a different offset from the series anchor's whenever a DST boundary
|
||||
// sits between them, or whenever the zone itself changed. Working in wall
|
||||
// clock keeps "09:00" meaning 09:00 at both ends.
|
||||
val seriesLocal = Instant.ofEpochMilli(seriesDtStartMillis)
|
||||
.atZone(original.writeZone(zone)).toLocalDateTime()
|
||||
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
|
||||
val shifted = seriesLocal.plus(wallClockShift)
|
||||
// An all-day series anchor must sit on a UTC midnight. A pure day move
|
||||
// already lands there (both ends are midnights), but *switching* a
|
||||
// recurring event to all-day shifts by a time-of-day too, so snap.
|
||||
val newSeriesLocal = if (updated.isAllDay) shifted.toLocalDate().atStartOfDay() else shifted
|
||||
val newSeriesStart = newSeriesLocal.atZone(updated.writeZone(zone))
|
||||
put(CalendarContract.Events.DTSTART, newSeriesStart.toInstant().toEpochMilli())
|
||||
put(CalendarContract.Events.DTEND, null)
|
||||
put(CalendarContract.Events.RRULE, updated.rrule)
|
||||
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
||||
@@ -118,8 +215,14 @@ internal fun buildEventUpdateValues(
|
||||
* provider clone the series row and apply these on top. Unlike the series
|
||||
* update there is no dirty check — the exception is a fresh row, so every
|
||||
* form-backed column is written (empty optionals as explicit NULLs, since the
|
||||
* clone starts from the parent's values). An exception is a single event:
|
||||
* DTEND, never RRULE/DURATION.
|
||||
* clone starts from the parent's values).
|
||||
*
|
||||
* The occurrence's length travels as DURATION, never DTEND: the provider
|
||||
* rejects DTEND on an exception outright (`CalendarProvider2`:
|
||||
* "Exceptions can't overwrite dtend") and derives the instance end from
|
||||
* 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).
|
||||
*/
|
||||
internal fun buildOccurrenceExceptionValues(
|
||||
form: EventForm,
|
||||
@@ -131,7 +234,7 @@ internal fun buildOccurrenceExceptionValues(
|
||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
@@ -140,6 +243,215 @@ internal fun buildOccurrenceExceptionValues(
|
||||
putAll(eventColorColumns(form.colorKey, form.color))
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
|
||||
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
||||
* is sync-adapter-owned and can't be updated in place). Recurring rows carry
|
||||
* [rrule]/[duration] (and any [rdate]/[exdate]) with a null [dtEndMillis];
|
||||
* one-off rows carry [dtEndMillis]. Colour is deliberately absent: a raw
|
||||
* `EVENT_COLOR` or account-scoped `EVENT_COLOR_KEY` may be invalid on the target
|
||||
* account, so the moved copy inherits the target calendar's colour instead.
|
||||
*/
|
||||
internal data class MasterEventSnapshot(
|
||||
val title: String,
|
||||
val isAllDay: Boolean,
|
||||
val dtStartMillis: Long,
|
||||
val dtEndMillis: Long?,
|
||||
val duration: String?,
|
||||
val rrule: String?,
|
||||
val rdate: String?,
|
||||
val exdate: String?,
|
||||
val timezone: String?,
|
||||
val availability: Int,
|
||||
val accessLevel: Int,
|
||||
val status: Int?,
|
||||
val location: String?,
|
||||
val description: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Column values re-creating [snapshot] as a fresh Events row on
|
||||
* [targetCalendarId], keeping its [uid] so `.ics` backup dedup and sync identity
|
||||
* survive the move. Preserves the recurrence skeleton (DTSTART/RRULE/DURATION,
|
||||
* RDATE/EXDATE) so the series' generated instances — and therefore the
|
||||
* ORIGINAL_INSTANCE_TIME of every copied exception — line up unchanged. The
|
||||
* caller layers the user's field edits on top with a normal series update.
|
||||
*/
|
||||
internal fun buildMovedMasterValues(
|
||||
snapshot: MasterEventSnapshot,
|
||||
targetCalendarId: Long,
|
||||
uid: String,
|
||||
): Map<String, Any?> = buildMap {
|
||||
put(CalendarContract.Events.CALENDAR_ID, targetCalendarId)
|
||||
put(CalendarContract.Events.UID_2445, uid)
|
||||
put(CalendarContract.Events.TITLE, snapshot.title)
|
||||
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
|
||||
if (snapshot.rrule != null) {
|
||||
put(CalendarContract.Events.RRULE, snapshot.rrule)
|
||||
snapshot.rdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.RDATE, it) }
|
||||
snapshot.exdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.EXDATE, it) }
|
||||
put(CalendarContract.Events.DURATION, snapshot.movedDuration())
|
||||
} else {
|
||||
snapshot.dtEndMillis?.let { put(CalendarContract.Events.DTEND, it) }
|
||||
}
|
||||
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
||||
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
||||
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
||||
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
|
||||
}
|
||||
|
||||
/** The recurring copy's DURATION: its own if present, else derived from DTEND. */
|
||||
private fun MasterEventSnapshot.movedDuration(): String = duration?.takeIf { it.isNotBlank() }
|
||||
?: rfc2445Duration((dtEndMillis ?: dtStartMillis) - dtStartMillis, isAllDay)
|
||||
|
||||
/**
|
||||
* Raw provider snapshot of one exception row of a recurring series (a modified
|
||||
* or cancelled occurrence, `ORIGINAL_ID` = the series). [originalInstanceMillis]
|
||||
* ties it to the occurrence it overrides; a [isCancelled] row only needs that.
|
||||
*/
|
||||
internal data class ExceptionRowSnapshot(
|
||||
val exceptionEventId: Long,
|
||||
val originalInstanceMillis: Long,
|
||||
val isCancelled: Boolean,
|
||||
val status: Int?,
|
||||
val title: String,
|
||||
val isAllDay: Boolean,
|
||||
val dtStartMillis: Long,
|
||||
val dtEndMillis: Long?,
|
||||
val duration: String?,
|
||||
val timezone: String?,
|
||||
val availability: Int,
|
||||
val accessLevel: Int,
|
||||
val location: String?,
|
||||
val description: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Column values replaying a *modified* occurrence [snapshot] against the moved
|
||||
* series via `CONTENT_EXCEPTION_URI`. Like [buildOccurrenceExceptionValues] the
|
||||
* length travels as DURATION (the provider rejects DTEND on an exception). A
|
||||
* cancelled occurrence is written separately (ORIGINAL_INSTANCE_TIME +
|
||||
* STATUS_CANCELED) — this builder is only for the modified case.
|
||||
*/
|
||||
internal fun buildCopiedExceptionValues(snapshot: ExceptionRowSnapshot): Map<String, Any?> =
|
||||
buildMap {
|
||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, snapshot.originalInstanceMillis)
|
||||
put(CalendarContract.Events.TITLE, snapshot.title)
|
||||
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
|
||||
put(
|
||||
CalendarContract.Events.DURATION,
|
||||
snapshot.duration?.takeIf { it.isNotBlank() }
|
||||
?: rfc2445Duration(
|
||||
(snapshot.dtEndMillis ?: snapshot.dtStartMillis) - snapshot.dtStartMillis,
|
||||
snapshot.isAllDay,
|
||||
),
|
||||
)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
|
||||
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
||||
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
||||
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
|
||||
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Column values for a *cancelled*-occurrence exception row ("delete only this
|
||||
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
|
||||
* provider clone the series row and cancel exactly this one instance.
|
||||
*
|
||||
* As with [buildOccurrenceExceptionValues], the occurrence must be anchored with
|
||||
* DTSTART + DURATION so the provider derives a single instance and clears the
|
||||
* inherited RRULE. A STATUS-only cancel skips that: the clone keeps the RRULE, so
|
||||
* the *whole series* is cancelled and every other occurrence disappears
|
||||
* (Codeberg #47). The occurrence's length/zone come straight from the series row
|
||||
* — cancelling never changes them.
|
||||
*/
|
||||
internal fun buildOccurrenceCancelValues(
|
||||
originalInstanceMillis: Long,
|
||||
dtStartMillis: Long,
|
||||
duration: String?,
|
||||
timezone: String?,
|
||||
allDay: Int,
|
||||
): Map<String, Any?> = buildMap {
|
||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis)
|
||||
put(CalendarContract.Events.DTSTART, dtStartMillis)
|
||||
put(CalendarContract.Events.DURATION, duration)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, timezone)
|
||||
put(CalendarContract.Events.ALL_DAY, allDay)
|
||||
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||
}
|
||||
|
||||
/**
|
||||
* The master-row columns that drop the occurrence at [occurrenceMillis] from a
|
||||
* series by adding it to `EXDATE` — the path for events that have **no
|
||||
* `_sync_id`** (a local calendar, or a synced event not yet pushed).
|
||||
*
|
||||
* A cancelled exception row (see [buildOccurrenceCancelValues]) only attaches to
|
||||
* its parent through `ORIGINAL_SYNC_ID`. Without a `_sync_id` the link never
|
||||
* forms, and the provider's expansion of the *parent* collapses — every other
|
||||
* occurrence disappears (Codeberg #47, reproduced on a local calendar). EXDATE
|
||||
* needs no link, and is the canonical iCalendar way to drop an occurrence, so a
|
||||
* sync adapter carries it upstream unchanged if the calendar later syncs.
|
||||
*
|
||||
* The whole time/recurrence set is rewritten alongside it on purpose. The
|
||||
* provider does **not** treat an EXDATE-only update as a recurrence change: it
|
||||
* leaves the expanded `Instances` rows untouched, so the occurrence stays visible
|
||||
* (and, symmetrically, un-excluding one leaves it hidden). Writing DTSTART with
|
||||
* it forces the re-expansion — but DTSTART *alone* makes the provider recompute
|
||||
* `lastDate` as if the event were a single instance, collapsing the series to its
|
||||
* first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
|
||||
* re-expands it correctly. All observed on a Pixel; see the #47 notes.
|
||||
*
|
||||
* EXDATE is a comma-separated list, so an existing one is appended to (a repeat
|
||||
* of the same occurrence is folded away). All-day series take the `VALUE=DATE`
|
||||
* form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`).
|
||||
*/
|
||||
internal fun buildOccurrenceExdateValues(
|
||||
existingExdate: String?,
|
||||
occurrenceMillis: Long,
|
||||
dtStartMillis: Long,
|
||||
rrule: String?,
|
||||
duration: String?,
|
||||
timezone: String?,
|
||||
allDay: Int,
|
||||
): Map<String, Any?> {
|
||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
|
||||
val existing = existingExdate?.split(',')
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
.orEmpty()
|
||||
val merged = (existing + stamp).distinct().joinToString(",")
|
||||
return mapOf(
|
||||
CalendarContract.Events.EXDATE to merged,
|
||||
CalendarContract.Events.DTSTART to dtStartMillis,
|
||||
CalendarContract.Events.RRULE to rrule,
|
||||
CalendarContract.Events.DURATION to duration,
|
||||
CalendarContract.Events.EVENT_TIMEZONE to timezone,
|
||||
CalendarContract.Events.ALL_DAY to allDay,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
|
||||
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
|
||||
* reads off the UTC calendar day.
|
||||
*/
|
||||
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String {
|
||||
val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC)
|
||||
return if (isAllDay) {
|
||||
"%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth)
|
||||
} else {
|
||||
"%04d%02d%02dT%02d%02d%02dZ".format(
|
||||
utc.year, utc.monthValue, utc.dayOfMonth,
|
||||
utc.hour, utc.minute, utc.second,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
|
||||
/**
|
||||
* One of the app's special-dates calendars, discovered by the marker stamped in
|
||||
* its `CAL_SYNC2` column — so the mirror can re-adopt its calendars even if the
|
||||
* stored ids in preferences were lost (e.g. an app-data wipe).
|
||||
*/
|
||||
data class ManagedCalendarRow(val id: Long, val type: SpecialDateType)
|
||||
|
||||
/**
|
||||
* A managed event as read back for the sync diff. [uid] is the deterministic
|
||||
* `Events.UID_2445` (`contact-<type>:<lookupKey>@calendula`) the mirror keys on;
|
||||
* [title], [dtStartMillis] and [rrule] are the managed columns compared against
|
||||
* the desired state to decide whether a targeted update is needed.
|
||||
*/
|
||||
data class ManagedEventRow(
|
||||
val eventId: Long,
|
||||
val uid: String,
|
||||
val title: String,
|
||||
val dtStartMillis: Long,
|
||||
val rrule: String?,
|
||||
)
|
||||
@@ -15,9 +15,18 @@ internal object CalendarProjection {
|
||||
// own we stash one in CAL_SYNC1 (synced rows put their sync token here,
|
||||
// so the mapper only reads it for local calendars).
|
||||
DESCRIPTION_COLUMN,
|
||||
// The special-dates marker (CAL_SYNC2) — the durable identity the app
|
||||
// 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
|
||||
const val MANAGED_MARKER_COLUMN: String = CalendarContract.Calendars.CAL_SYNC2
|
||||
|
||||
/** Namespace prefix of every managed-calendar [MANAGED_MARKER_COLUMN] value. */
|
||||
const val MANAGED_MARKER_PREFIX = "calendula.specialdates"
|
||||
|
||||
const val IDX_ID = 0
|
||||
const val IDX_DISPLAY_NAME = 1
|
||||
@@ -27,6 +36,8 @@ internal object CalendarProjection {
|
||||
const val IDX_VISIBLE = 5
|
||||
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 {
|
||||
@@ -75,6 +86,9 @@ internal object EventDetailProjection {
|
||||
CalendarContract.Events.EVENT_TIMEZONE,
|
||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||
CalendarContract.Events.EVENT_COLOR_KEY,
|
||||
// Recurring rows carry DURATION instead of DTEND; the detail screen
|
||||
// needs it to render a series opened without a named occurrence.
|
||||
CalendarContract.Events.DURATION,
|
||||
)
|
||||
|
||||
const val IDX_EVENT_ID = 0
|
||||
@@ -95,6 +109,7 @@ internal object EventDetailProjection {
|
||||
const val IDX_EVENT_TIMEZONE = 15
|
||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||
const val IDX_EVENT_COLOR_KEY = 17
|
||||
const val IDX_DURATION = 18
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,6 +154,124 @@ internal object EventExportProjection {
|
||||
const val IDX_CALENDAR_ID = 13
|
||||
}
|
||||
|
||||
/**
|
||||
* Master/one-off Events rows matched by a full-text search. Like
|
||||
* [EventExportProjection] it reads the Events table directly (so the search is
|
||||
* unbounded in time), carrying DURATION for recurring rows that have no DTEND.
|
||||
* Colour folds the calendar fallback like [InstanceProjection].
|
||||
*/
|
||||
internal object SearchProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Events._ID,
|
||||
CalendarContract.Events.CALENDAR_ID,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.DTEND,
|
||||
CalendarContract.Events.DURATION,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events.EVENT_COLOR,
|
||||
CalendarContract.Events.CALENDAR_COLOR,
|
||||
CalendarContract.Events.EVENT_LOCATION,
|
||||
// Recurrence markers: a non-empty RRULE or RDATE means the result should
|
||||
// display its nearest occurrence, not the series-start DTSTART.
|
||||
CalendarContract.Events.RRULE,
|
||||
CalendarContract.Events.RDATE,
|
||||
)
|
||||
|
||||
const val IDX_ID = 0
|
||||
const val IDX_CALENDAR_ID = 1
|
||||
const val IDX_TITLE = 2
|
||||
const val IDX_DTSTART = 3
|
||||
const val IDX_DTEND = 4
|
||||
const val IDX_DURATION = 5
|
||||
const val IDX_ALL_DAY = 6
|
||||
const val IDX_EVENT_COLOR = 7
|
||||
const val IDX_CALENDAR_COLOR = 8
|
||||
const val IDX_LOCATION = 9
|
||||
const val IDX_RRULE = 10
|
||||
const val IDX_RDATE = 11
|
||||
}
|
||||
|
||||
/**
|
||||
* The master/one-off Events row of an event about to be moved to another
|
||||
* calendar, read for a verbatim re-insert (see [MasterEventSnapshot]). Carries
|
||||
* the full recurrence skeleton (RRULE/DURATION, RDATE/EXDATE) so the moved copy
|
||||
* generates the same instances, and `UID_2445` so identity survives the move.
|
||||
*/
|
||||
internal object MoveMasterProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Events.UID_2445,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.DTEND,
|
||||
CalendarContract.Events.DURATION,
|
||||
CalendarContract.Events.RRULE,
|
||||
CalendarContract.Events.RDATE,
|
||||
CalendarContract.Events.EXDATE,
|
||||
CalendarContract.Events.EVENT_TIMEZONE,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events.AVAILABILITY,
|
||||
CalendarContract.Events.ACCESS_LEVEL,
|
||||
CalendarContract.Events.STATUS,
|
||||
CalendarContract.Events.EVENT_LOCATION,
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
)
|
||||
|
||||
const val IDX_UID = 0
|
||||
const val IDX_TITLE = 1
|
||||
const val IDX_DTSTART = 2
|
||||
const val IDX_DTEND = 3
|
||||
const val IDX_DURATION = 4
|
||||
const val IDX_RRULE = 5
|
||||
const val IDX_RDATE = 6
|
||||
const val IDX_EXDATE = 7
|
||||
const val IDX_EVENT_TIMEZONE = 8
|
||||
const val IDX_ALL_DAY = 9
|
||||
const val IDX_AVAILABILITY = 10
|
||||
const val IDX_ACCESS_LEVEL = 11
|
||||
const val IDX_STATUS = 12
|
||||
const val IDX_LOCATION = 13
|
||||
const val IDX_DESCRIPTION = 14
|
||||
}
|
||||
|
||||
/**
|
||||
* The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to
|
||||
* replay them against a moved copy (see [ExceptionRowSnapshot]). Both modified
|
||||
* occurrences and cancellations (`STATUS_CANCELED`) are read; the query filters
|
||||
* `DELETED = 0` so provider tombstones aren't replayed.
|
||||
*/
|
||||
internal object ExceptionProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Events._ID,
|
||||
CalendarContract.Events.ORIGINAL_INSTANCE_TIME,
|
||||
CalendarContract.Events.STATUS,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.DTEND,
|
||||
CalendarContract.Events.DURATION,
|
||||
CalendarContract.Events.EVENT_TIMEZONE,
|
||||
CalendarContract.Events.AVAILABILITY,
|
||||
CalendarContract.Events.ACCESS_LEVEL,
|
||||
CalendarContract.Events.EVENT_LOCATION,
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
)
|
||||
|
||||
const val IDX_ID = 0
|
||||
const val IDX_ORIGINAL_INSTANCE_TIME = 1
|
||||
const val IDX_STATUS = 2
|
||||
const val IDX_TITLE = 3
|
||||
const val IDX_ALL_DAY = 4
|
||||
const val IDX_DTSTART = 5
|
||||
const val IDX_DTEND = 6
|
||||
const val IDX_DURATION = 7
|
||||
const val IDX_EVENT_TIMEZONE = 8
|
||||
const val IDX_AVAILABILITY = 9
|
||||
const val IDX_ACCESS_LEVEL = 10
|
||||
const val IDX_LOCATION = 11
|
||||
const val IDX_DESCRIPTION = 12
|
||||
}
|
||||
|
||||
internal object AttendeeProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
|
||||
/**
|
||||
* Map an Events-table row (a search hit) to an [EventInstance]. Unlike the
|
||||
* Instances query this reads the series master, so there is no instance id (the
|
||||
* event id stands in as the list key) and recurring rows carry DURATION instead
|
||||
* of DTEND — reconstruct the end the same way the `.ics` export does.
|
||||
*/
|
||||
internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||
// A pre-1970 series anchor is a legitimately negative epoch-millis DTSTART
|
||||
// (see EventDetailMapper / issue #34); drop only a genuinely absent one, so
|
||||
// long-running birthdays/anniversaries still surface in search.
|
||||
if (isNull(SearchProjection.IDX_DTSTART)) return null
|
||||
val dtStart = getLong(SearchProjection.IDX_DTSTART)
|
||||
val end = when {
|
||||
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
|
||||
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 color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
|
||||
getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
||||
} else {
|
||||
getInt(SearchProjection.IDX_EVENT_COLOR)
|
||||
}
|
||||
val eventId = getLong(SearchProjection.IDX_ID)
|
||||
|
||||
return EventInstance(
|
||||
instanceId = eventId,
|
||||
eventId = eventId,
|
||||
calendarId = getLong(SearchProjection.IDX_CALENDAR_ID),
|
||||
title = title,
|
||||
start = dtStart.toKotlinInstantFromEpochMillis(),
|
||||
end = end.toKotlinInstantFromEpochMillis(),
|
||||
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
|
||||
color = color,
|
||||
location = getString(SearchProjection.IDX_LOCATION),
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
fun Long.toKotlinInstantFromEpochMillis(): Instant = Instant.fromEpochMilliseconds(this)
|
||||
|
||||
fun Instant.toEpochMillis(): Long = toEpochMilliseconds()
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Resolves the localized calendar names/templates and per-type colours the
|
||||
* mirror creates its calendars with. The colours are picked from the shared
|
||||
* palette so managed calendars look native alongside user calendars.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidSpecialDatesCalendarSpec @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : SpecialDatesCalendarSpec {
|
||||
|
||||
override fun displayName(type: SpecialDateType): String = context.getString(
|
||||
when (type) {
|
||||
SpecialDateType.Birthday -> R.string.special_dates_calendar_birthday
|
||||
SpecialDateType.Anniversary -> R.string.special_dates_calendar_anniversary
|
||||
SpecialDateType.Custom -> R.string.special_dates_calendar_custom
|
||||
},
|
||||
)
|
||||
|
||||
override fun defaultTitleTemplate(type: SpecialDateType): String = context.getString(
|
||||
when (type) {
|
||||
SpecialDateType.Birthday -> R.string.special_dates_default_title_birthday
|
||||
SpecialDateType.Anniversary -> R.string.special_dates_default_title_anniversary
|
||||
SpecialDateType.Custom -> R.string.special_dates_default_title_custom
|
||||
},
|
||||
)
|
||||
|
||||
override fun color(type: SpecialDateType): Int = when (type) {
|
||||
SpecialDateType.Birthday -> CalendarColorPalette.Purple
|
||||
SpecialDateType.Anniversary -> CalendarColorPalette.Red
|
||||
SpecialDateType.Custom -> CalendarColorPalette.Blue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.Event
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Reads the dated `Event` rows (birthdays, anniversaries, custom dates) of the
|
||||
* device's contacts. Read-only and offline — the one-way source for the
|
||||
* special-dates mirror. Requires `READ_CONTACTS`; returns an empty list when
|
||||
* the permission is absent so the sync can degrade to a stalled state rather
|
||||
* than crash.
|
||||
*/
|
||||
interface ContactSpecialDatesDataSource {
|
||||
fun hasPermission(): Boolean
|
||||
|
||||
/** All usable contact special-dates, deduplicated per contact and type. */
|
||||
fun readSpecialDates(): List<ContactSpecialDate>
|
||||
}
|
||||
|
||||
/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */
|
||||
fun Context.hasContactsPermission(): Boolean =
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
|
||||
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
|
||||
Event.TYPE_BIRTHDAY -> SpecialDateType.Birthday
|
||||
Event.TYPE_ANNIVERSARY -> SpecialDateType.Anniversary
|
||||
else -> SpecialDateType.Custom
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class AndroidContactSpecialDatesDataSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ContactSpecialDatesDataSource {
|
||||
|
||||
override fun hasPermission(): Boolean = context.hasContactsPermission()
|
||||
|
||||
override fun readSpecialDates(): List<ContactSpecialDate> {
|
||||
if (!hasPermission()) return emptyList()
|
||||
val resolver = context.contentResolver
|
||||
// A contact can carry the same date more than once (multiple raw contacts
|
||||
// under one aggregate); dedup on the mirror's reconciliation key so exact
|
||||
// duplicates collapse while genuinely distinct dates (two custom events on
|
||||
// one contact) are all kept. Ordered by Data._ID so which of two truly
|
||||
// conflicting rows wins is stable across syncs (no event ping-pong).
|
||||
val seen = HashSet<String>()
|
||||
val result = ArrayList<ContactSpecialDate>()
|
||||
runCatching {
|
||||
resolver.query(
|
||||
ContactsContract.Data.CONTENT_URI,
|
||||
PROJECTION,
|
||||
"${ContactsContract.Data.MIMETYPE} = ?",
|
||||
arrayOf(Event.CONTENT_ITEM_TYPE),
|
||||
"${ContactsContract.Data._ID} ASC",
|
||||
)?.use { c ->
|
||||
val idxDate = c.getColumnIndexOrThrow(Event.START_DATE)
|
||||
val idxType = c.getColumnIndexOrThrow(Event.TYPE)
|
||||
val idxLabel = c.getColumnIndexOrThrow(Event.LABEL)
|
||||
val idxLookup = c.getColumnIndexOrThrow(ContactsContract.Data.LOOKUP_KEY)
|
||||
val idxName = c.getColumnIndexOrThrow(ContactsContract.Data.DISPLAY_NAME_PRIMARY)
|
||||
while (c.moveToNext()) {
|
||||
val lookup = c.getString(idxLookup)?.takeIf { it.isNotEmpty() } ?: continue
|
||||
val parts = parseContactEventDate(c.getString(idxDate)) ?: continue
|
||||
val type = specialDateTypeForRawEventType(c.getInt(idxType))
|
||||
val date = ContactSpecialDate(
|
||||
lookupKey = lookup,
|
||||
displayName = c.getString(idxName)?.trim().orEmpty(),
|
||||
type = type,
|
||||
month = parts.month,
|
||||
day = parts.day,
|
||||
year = parts.year,
|
||||
label = c.getString(idxLabel)?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
if (seen.add(date.managedUid())) result += date
|
||||
}
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Reading contact special-dates failed", it) }
|
||||
return result
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ContactSpecialDates"
|
||||
|
||||
val PROJECTION = arrayOf(
|
||||
ContactsContract.Data.LOOKUP_KEY,
|
||||
ContactsContract.Data.DISPLAY_NAME_PRIMARY,
|
||||
Event.START_DATE,
|
||||
Event.TYPE,
|
||||
Event.LABEL,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
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 de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Schedules the contact special-dates mirror. Birthdays change rarely, so this
|
||||
* is deliberately cheap: a **daily** periodic reconcile, plus an immediate run
|
||||
* on enable / "Sync now" and a debounced one when the app is foregrounded. No
|
||||
* ContentObserver — it would need the process alive and buys almost nothing for
|
||||
* once-a-year events. Everything stays offline (local contacts → local calendar).
|
||||
*/
|
||||
object SpecialDatesScheduler {
|
||||
|
||||
private const val WORK_NAME = "special-dates-sync"
|
||||
private const val WORK_NAME_NOW = "special-dates-sync-now"
|
||||
private const val WORK_NAME_FOREGROUND = "special-dates-sync-foreground"
|
||||
private const val KEY_FOREGROUND = "foreground"
|
||||
|
||||
/** Enqueue (or cancel) the daily periodic reconcile to match [enabled]. */
|
||||
fun apply(context: Context, enabled: Boolean) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
if (!enabled) {
|
||||
workManager.cancelUniqueWork(WORK_NAME)
|
||||
workManager.cancelUniqueWork(WORK_NAME_NOW)
|
||||
workManager.cancelUniqueWork(WORK_NAME_FOREGROUND)
|
||||
return
|
||||
}
|
||||
val request = PeriodicWorkRequestBuilder<SpecialDatesSyncWorker>(1, TimeUnit.DAYS)
|
||||
// Delay the first periodic run so it never overlaps an immediate run.
|
||||
.setInitialDelay(1, TimeUnit.DAYS)
|
||||
.build()
|
||||
workManager.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one reconcile immediately. [foreground] runs (the app resuming) are
|
||||
* debounced inside the worker and use their own work name, so a frequent
|
||||
* foreground resync can never REPLACE — and swallow — a pending enable /
|
||||
* "Sync now" run, which always syncs. The engine serializes the two if they
|
||||
* overlap.
|
||||
*/
|
||||
fun runNow(context: Context, foreground: Boolean = false) {
|
||||
val request = OneTimeWorkRequestBuilder<SpecialDatesSyncWorker>()
|
||||
.setInputData(Data.Builder().putBoolean(KEY_FOREGROUND, foreground).build())
|
||||
.build()
|
||||
val name = if (foreground) WORK_NAME_FOREGROUND else WORK_NAME_NOW
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(name, ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
|
||||
internal const val INPUT_FOREGROUND = KEY_FOREGROUND
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the [SpecialDatesSyncEngine]. Pulls its collaborators through a Hilt
|
||||
* [EntryPoint] so it works under WorkManager's default worker factory. Records
|
||||
* the run for the settings status line; a missing permission parks the feature
|
||||
* in a stalled state (surfaced in settings) rather than retrying forever.
|
||||
*/
|
||||
class SpecialDatesSyncWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Deps {
|
||||
fun settingsPrefs(): SettingsPrefs
|
||||
fun syncEngine(): SpecialDatesSyncEngine
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||
val prefs = deps.settingsPrefs()
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
// A foreground resume can fire often — skip if we synced recently.
|
||||
val foreground = inputData.getBoolean(SpecialDatesScheduler.INPUT_FOREGROUND, false)
|
||||
if (foreground && now - prefs.specialDatesLastForegroundSync.first() < FOREGROUND_DEBOUNCE_MILLIS) {
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
return try {
|
||||
when (val result = deps.syncEngine().sync()) {
|
||||
// The engine re-checks the toggle, so already-queued work never
|
||||
// revives a disabled feature; don't record a run either.
|
||||
SpecialDatesSyncResult.Disabled -> return Result.success()
|
||||
else -> {
|
||||
val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) {
|
||||
SpecialDatesStalledReason.PermissionRevoked
|
||||
} else {
|
||||
null
|
||||
}
|
||||
prefs.recordSpecialDatesRun(now, stalled)
|
||||
}
|
||||
}
|
||||
if (foreground) prefs.setSpecialDatesLastForegroundSync(now)
|
||||
Result.success()
|
||||
} catch (e: SecurityException) {
|
||||
// A revoked calendar/contacts permission won't fix itself on retry —
|
||||
// park the feature in a stalled state (surfaced in settings) instead
|
||||
// of retrying with backoff forever.
|
||||
Log.w(TAG, "Special-dates sync lacks a required permission", e)
|
||||
prefs.recordSpecialDatesRun(now, SpecialDatesStalledReason.PermissionRevoked)
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Special-dates sync failed", e)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SpecialDatesSync"
|
||||
|
||||
/** Skip a foreground-triggered sync if one ran within this window (4h). */
|
||||
private const val FOREGROUND_DEBOUNCE_MILLIS = 4L * 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.anchorDate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle
|
||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Outcome of one mirror reconcile, reported back so the worker can record status. */
|
||||
enum class SpecialDatesSyncResult { Success, Disabled, PermissionMissing }
|
||||
|
||||
/**
|
||||
* Localized, per-type presentation the engine needs but can't derive purely
|
||||
* (calendar display name, colour, and the default title template). Kept behind
|
||||
* an interface so the engine's diff stays unit-testable without Android
|
||||
* resources.
|
||||
*/
|
||||
interface SpecialDatesCalendarSpec {
|
||||
fun displayName(type: SpecialDateType): String
|
||||
fun color(type: SpecialDateType): Int
|
||||
fun defaultTitleTemplate(type: SpecialDateType): String
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored title template for [type], falling back to the localized default
|
||||
* when blank/absent — the one resolution rule shared by the sync (what gets
|
||||
* written) and the settings editor (what gets shown).
|
||||
*/
|
||||
fun SpecialDatesCalendarSpec.resolveTitleTemplate(
|
||||
type: SpecialDateType,
|
||||
stored: Map<SpecialDateType, String>,
|
||||
): String = stored[type]?.takeIf { it.isNotBlank() } ?: defaultTitleTemplate(type)
|
||||
|
||||
/**
|
||||
* Mirrors contact birthdays/anniversaries/custom dates into local calendars,
|
||||
* one per type. Every reconcile is an idempotent diff keyed on the deterministic
|
||||
* `UID_2445` ([managedEventUid]): new contacts are inserted (seeding user-owned
|
||||
* fields once), changed contacts get a *targeted* managed-column update, and
|
||||
* removed contacts are deleted — so a user's own edits (reminders, location,
|
||||
* notes) are never clobbered. See docs/design/contact-special-dates.md.
|
||||
*/
|
||||
@Singleton
|
||||
class SpecialDatesSyncEngine @Inject constructor(
|
||||
private val contacts: ContactSpecialDatesDataSource,
|
||||
private val calendars: CalendarDataSource,
|
||||
private val prefs: SettingsPrefs,
|
||||
private val spec: SpecialDatesCalendarSpec,
|
||||
) {
|
||||
|
||||
// Serializes calendar-lifecycle work: two overlapping syncs (the daily job
|
||||
// racing a "Sync now"/foreground run) would each see no managed calendar and
|
||||
// both create one; a teardown racing an in-flight sync would delete calendars
|
||||
// the sync then recreates. Holding this for the whole of sync()/teardown()
|
||||
// makes those check-then-act sequences atomic, and — because sync() re-reads
|
||||
// the enabled flag inside the lock — a teardown always wins the race.
|
||||
private val lifecycleMutex = Mutex()
|
||||
|
||||
/**
|
||||
* Reconcile every enabled type against the device's contacts. Returns why it
|
||||
* stopped early (disabled / permission gone) or [SpecialDatesSyncResult.Success].
|
||||
*/
|
||||
suspend fun sync(): SpecialDatesSyncResult = lifecycleMutex.withLock {
|
||||
if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled
|
||||
if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing
|
||||
|
||||
val enabledTypes = prefs.specialDatesTypes.first()
|
||||
val calendarByType = reconcileCalendars(enabledTypes)
|
||||
|
||||
val desiredByType = contacts.readSpecialDates().groupBy { it.type }
|
||||
val reminderCtx = readReminderContext()
|
||||
val templates = prefs.specialDatesTitleTemplates.first()
|
||||
val showYear = prefs.specialDatesShowYear.first()
|
||||
|
||||
calendarByType.forEach { (type, calendarId) ->
|
||||
val template = spec.resolveTitleTemplate(type, templates)
|
||||
syncType(
|
||||
calendarId = calendarId,
|
||||
type = type,
|
||||
contactsOfType = desiredByType[type].orEmpty(),
|
||||
template = template,
|
||||
showYear = showYear,
|
||||
reminderCtx = reminderCtx,
|
||||
)
|
||||
}
|
||||
SpecialDatesSyncResult.Success
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the reminder default for a type's managed calendar and apply it to
|
||||
* **all** its existing events too (not just future ones) — for managed
|
||||
* calendars the reminder is a calendar-level setting. Persists the per-calendar
|
||||
* all-day override so new events keep matching. No-op if the calendar for
|
||||
* [type] doesn't exist yet.
|
||||
*/
|
||||
suspend fun applyReminders(type: SpecialDateType, override: ReminderOverride) {
|
||||
val calendarId = prefs.specialDatesCalendars.first()[type] ?: return
|
||||
prefs.setCalendarAllDayReminderOverride(calendarId, override)
|
||||
val minutes = when (override) {
|
||||
ReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
||||
ReminderOverride.None -> emptyList()
|
||||
is ReminderOverride.Minutes -> override.minutes
|
||||
}
|
||||
calendars.applyManagedCalendarReminders(
|
||||
calendarId = calendarId,
|
||||
allDayReminderTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
|
||||
minutes = minutes,
|
||||
)
|
||||
}
|
||||
|
||||
/** Delete every managed calendar and forget its id — used when the feature is turned off. */
|
||||
suspend fun teardown() = lifecycleMutex.withLock {
|
||||
calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) }
|
||||
SpecialDateType.entries.forEach { prefs.setSpecialDatesCalendarId(it, null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure each enabled type has exactly one managed calendar (adopting an
|
||||
* existing one, or the stored id if it still exists, else creating one) and
|
||||
* that disabled types have none. Returns the calendar id per enabled type.
|
||||
*/
|
||||
private suspend fun reconcileCalendars(enabledTypes: Set<SpecialDateType>): Map<SpecialDateType, Long> {
|
||||
val foundByType = calendars.findManagedCalendars()
|
||||
.groupBy({ it.type }, { it.id })
|
||||
.mapValues { it.value.first() }
|
||||
val stored = prefs.specialDatesCalendars.first()
|
||||
val result = LinkedHashMap<SpecialDateType, Long>()
|
||||
for (type in SpecialDateType.entries) {
|
||||
// A stored id counts only if the calendar still exists (the user may
|
||||
// have deleted it in system settings); otherwise adopt a found one.
|
||||
val existingId = stored[type]?.takeIf { id -> foundByType.containsValue(id) }
|
||||
?: foundByType[type]
|
||||
if (type in enabledTypes) {
|
||||
val id = existingId ?: createCalendar(type)
|
||||
if (stored[type] != id) prefs.setSpecialDatesCalendarId(type, id)
|
||||
result[type] = id
|
||||
} else {
|
||||
if (existingId != null) calendars.deleteCalendar(existingId)
|
||||
if (stored.containsKey(type)) prefs.setSpecialDatesCalendarId(type, null)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun createCalendar(type: SpecialDateType): Long {
|
||||
// reconcileCalendars persists the id — the single place ids are recorded.
|
||||
val id = calendars.createManagedCalendar(spec.displayName(type), spec.color(type), type)
|
||||
// Seed a useful all-day reminder default (on the day + a week before), so
|
||||
// birthdays get lead time out of the box. Per-calendar, user-adjustable —
|
||||
// only set when the user hasn't already configured this calendar.
|
||||
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
|
||||
prefs.setCalendarAllDayReminderOverride(
|
||||
id,
|
||||
ReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
||||
)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
private suspend fun syncType(
|
||||
calendarId: Long,
|
||||
type: SpecialDateType,
|
||||
contactsOfType: List<ContactSpecialDate>,
|
||||
template: String,
|
||||
showYear: Boolean,
|
||||
reminderCtx: ReminderContext,
|
||||
) {
|
||||
val built = contactsOfType.map { sd ->
|
||||
buildManagedEvent(calendarId, type, sd, template, showYear, reminderCtx)
|
||||
}
|
||||
val diff = diffManagedEvents(built.map { it.desired }, calendars.queryManagedEvents(calendarId))
|
||||
val formByUid = built.associate { it.desired.uid to it.form }
|
||||
diff.insertUids.forEach { uid ->
|
||||
calendars.insertManagedEvent(formByUid.getValue(uid), uid, reminderCtx.allDayTimeMinutes)
|
||||
}
|
||||
diff.updates.forEach { calendars.updateManagedFields(it.eventId, it.columns) }
|
||||
diff.deleteEventIds.forEach { calendars.deleteEvent(it) }
|
||||
}
|
||||
|
||||
private fun buildManagedEvent(
|
||||
calendarId: Long,
|
||||
type: SpecialDateType,
|
||||
sd: ContactSpecialDate,
|
||||
template: String,
|
||||
showYear: Boolean,
|
||||
reminderCtx: ReminderContext,
|
||||
): BuiltManagedEvent {
|
||||
val anchor = sd.anchorDate()
|
||||
val start = LocalDateTime(anchor, LocalTime(0, 0))
|
||||
// The source year is static and correct on every occurrence (unlike age).
|
||||
val year = if (showYear) sd.year else null
|
||||
val title = renderSpecialDateTitle(template, sd.displayName, year)
|
||||
val form = EventForm(
|
||||
calendarId = calendarId,
|
||||
title = title,
|
||||
isAllDay = true,
|
||||
start = start,
|
||||
end = start,
|
||||
reminders = reminderCtx.resolveAllDay(calendarId),
|
||||
availability = Availability.Free,
|
||||
rrule = YEARLY_RRULE,
|
||||
)
|
||||
val dtStartMillis = form.toWriteTimes(ZoneId.systemDefault()).dtStartMillis
|
||||
return BuiltManagedEvent(
|
||||
desired = ManagedEventDesired(
|
||||
uid = sd.managedUid(),
|
||||
title = title,
|
||||
dtStartMillis = dtStartMillis,
|
||||
rrule = YEARLY_RRULE,
|
||||
),
|
||||
form = form,
|
||||
)
|
||||
}
|
||||
|
||||
// Managed events are always all-day, so only the all-day defaults apply.
|
||||
private suspend fun readReminderContext(): ReminderContext = ReminderContext(
|
||||
allDayGlobal = prefs.defaultAllDayReminderMinutes.first(),
|
||||
allDayOverrides = prefs.perCalendarAllDayReminderOverride.first(),
|
||||
allDayTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
|
||||
)
|
||||
|
||||
private data class ReminderContext(
|
||||
val allDayGlobal: List<Int>,
|
||||
val allDayOverrides: Map<Long, List<Int>>,
|
||||
val allDayTimeMinutes: Int,
|
||||
) {
|
||||
/** Same semantics as `resolveDefaultReminder`: present-empty = explicit none. */
|
||||
fun resolveAllDay(calendarId: Long): List<Int> =
|
||||
allDayOverrides[calendarId] ?: allDayGlobal
|
||||
}
|
||||
|
||||
private data class BuiltManagedEvent(val desired: ManagedEventDesired, val form: EventForm)
|
||||
|
||||
private companion object {
|
||||
/** "On the day" (0) + one week before (7 days), as all-day lead minutes. */
|
||||
val DEFAULT_REMINDER_MINUTES = listOf(0, 7 * 24 * 60)
|
||||
val YEARLY_RRULE = SimpleRecurrence(freq = RecurrenceFreq.Yearly).toRRule()
|
||||
}
|
||||
}
|
||||
|
||||
/** The managed columns of a desired event, compared against the existing row. */
|
||||
internal data class ManagedEventDesired(
|
||||
val uid: String,
|
||||
val title: String,
|
||||
val dtStartMillis: Long,
|
||||
val rrule: String?,
|
||||
)
|
||||
|
||||
internal data class ManagedFieldUpdate(val eventId: Long, val columns: Map<String, Any?>)
|
||||
|
||||
internal data class ManagedDiff(
|
||||
val insertUids: List<String>,
|
||||
val updates: List<ManagedFieldUpdate>,
|
||||
val deleteEventIds: List<Long>,
|
||||
)
|
||||
|
||||
/**
|
||||
* The idempotent diff at the heart of the mirror, keyed on `UID_2445`:
|
||||
* desired-not-existing → insert, existing-not-desired → delete, and for events
|
||||
* in both only the *changed* managed columns (title/dtstart/rrule) are emitted —
|
||||
* so a re-run with no contact changes produces nothing, and a managed update
|
||||
* never touches user-owned columns or reminder rows. Pure, so it's unit-tested.
|
||||
*/
|
||||
internal fun diffManagedEvents(
|
||||
desired: List<ManagedEventDesired>,
|
||||
existing: List<ManagedEventRow>,
|
||||
): ManagedDiff {
|
||||
val desiredByUid = desired.associateBy { it.uid }
|
||||
val existingByUid = existing.associateBy { it.uid }
|
||||
val insertUids = desired.filter { it.uid !in existingByUid }.map { it.uid }
|
||||
val deleteEventIds = existing.filter { it.uid !in desiredByUid }.map { it.eventId }
|
||||
val updates = buildList {
|
||||
for (d in desired) {
|
||||
val row = existingByUid[d.uid] ?: continue
|
||||
val columns = buildMap<String, Any?> {
|
||||
if (row.title != d.title) put(CalendarContract.Events.TITLE, d.title)
|
||||
if (row.dtStartMillis != d.dtStartMillis) {
|
||||
put(CalendarContract.Events.DTSTART, d.dtStartMillis)
|
||||
}
|
||||
if (row.rrule != d.rrule) put(CalendarContract.Events.RRULE, d.rrule)
|
||||
}
|
||||
if (columns.isNotEmpty()) add(ManagedFieldUpdate(row.eventId, columns))
|
||||
}
|
||||
}
|
||||
return ManagedDiff(insertUids, updates, deleteEventIds)
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.crash
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.Build
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Privacy-respecting crash capture (prod-readiness item 10). On an uncaught
|
||||
* exception it writes a self-contained report to the app's private storage and
|
||||
* then chains to the platform's default handler, so the process still dies
|
||||
* normally (and the OS shows its own "stopped" dialog). Nothing is uploaded —
|
||||
* the app holds no `INTERNET` permission. The user submits the report later,
|
||||
* by hand, as a Gitea issue (see the ui/crash surfaces).
|
||||
*
|
||||
* The report is built from a fixed [CrashContext] allowlist — app/Android/device
|
||||
* version, locale, time, and the stack trace — and **nothing else**: no device
|
||||
* identifiers, no account names, no calendar/event content, no logcat. The user
|
||||
* is always shown the full text before it leaves the device.
|
||||
*/
|
||||
object CrashReporter {
|
||||
|
||||
/**
|
||||
* Install the handler. Called first thing in `CalendulaApp.onCreate()` so it
|
||||
* also catches crashes during startup. The handler swallows nothing — it
|
||||
* persists, then delegates to the previously-registered handler.
|
||||
*/
|
||||
fun install(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
// Capturing must never mask the original crash, so guard every step.
|
||||
runCatching {
|
||||
val now = System.currentTimeMillis()
|
||||
writeReport(appContext, buildCrashReport(CrashContext.from(appContext), throwable, now))
|
||||
recordCrashTime(appContext, now)
|
||||
}
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
/** The persisted report from the last crash, or null if there is none. */
|
||||
fun pendingReport(context: Context): String? {
|
||||
val file = reportFile(context)
|
||||
return if (file.exists()) runCatching { file.readText() }.getOrNull()?.takeIf { it.isNotBlank() } else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to surface the report unprompted (on the next launch): a report
|
||||
* exists and the user hasn't already waved this one away. Settings reaches
|
||||
* the report via [pendingReport] regardless, so "Not now" only stops the
|
||||
* auto-prompt — it doesn't discard the report.
|
||||
*/
|
||||
fun shouldPrompt(context: Context): Boolean =
|
||||
reportFile(context).exists() && !dismissedFile(context).exists()
|
||||
|
||||
/** Stop auto-prompting for the current report without discarding it. */
|
||||
fun dismissPrompt(context: Context) {
|
||||
runCatching { dismissedFile(context).apply { parentFile?.mkdirs() }.writeText("") }
|
||||
}
|
||||
|
||||
/** Drop the persisted report once the user has reported it (or from Settings). */
|
||||
fun clearReport(context: Context) {
|
||||
runCatching { reportFile(context).delete() }
|
||||
runCatching { dismissedFile(context).delete() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app appears to be in a startup crash-loop: at least
|
||||
* [LOOP_THRESHOLD] crashes inside [LOOP_WINDOW_MS]. In that case the main UI
|
||||
* can't be trusted to start, so the caller routes straight to the standalone
|
||||
* report screen instead of re-entering the crashing graph.
|
||||
*/
|
||||
fun isCrashLoop(context: Context): Boolean {
|
||||
val times = readCrashTimes(context)
|
||||
if (times.size < LOOP_THRESHOLD) return false
|
||||
val recent = times.sortedDescending()
|
||||
return recent[0] - recent[LOOP_THRESHOLD - 1] <= LOOP_WINDOW_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the app as having started successfully, resetting the loop counter so
|
||||
* an ordinary single crash much later never trips loop detection. The
|
||||
* pending report itself is kept — only the timing trail is cleared.
|
||||
*/
|
||||
fun markHealthy(context: Context) {
|
||||
runCatching { timesFile(context).delete() }
|
||||
}
|
||||
|
||||
// --- persistence -------------------------------------------------------
|
||||
|
||||
private fun writeReport(context: Context, report: String) {
|
||||
val file = reportFile(context).apply { parentFile?.mkdirs() }
|
||||
file.writeText(report.take(MAX_REPORT_CHARS))
|
||||
// A fresh crash should prompt again, even if the previous one was waved away.
|
||||
runCatching { dismissedFile(context).delete() }
|
||||
}
|
||||
|
||||
private fun recordCrashTime(context: Context, nowMillis: Long) {
|
||||
val kept = (readCrashTimes(context) + nowMillis).takeLast(MAX_TIMES)
|
||||
timesFile(context).apply { parentFile?.mkdirs() }
|
||||
.writeText(kept.joinToString("\n"))
|
||||
}
|
||||
|
||||
private fun readCrashTimes(context: Context): List<Long> {
|
||||
val file = timesFile(context)
|
||||
if (!file.exists()) return emptyList()
|
||||
return runCatching { file.readLines().mapNotNull { it.trim().toLongOrNull() } }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun crashDir(context: Context) = File(context.filesDir, CRASH_DIR)
|
||||
private fun reportFile(context: Context) = File(crashDir(context), REPORT_FILE)
|
||||
private fun timesFile(context: Context) = File(crashDir(context), TIMES_FILE)
|
||||
private fun dismissedFile(context: Context) = File(crashDir(context), DISMISSED_FILE)
|
||||
|
||||
private const val CRASH_DIR = "crash"
|
||||
private const val REPORT_FILE = "last_crash.txt"
|
||||
private const val TIMES_FILE = "crash_times.txt"
|
||||
private const val DISMISSED_FILE = "dismissed"
|
||||
private const val MAX_TIMES = 5
|
||||
private const val MAX_REPORT_CHARS = 64 * 1024
|
||||
private const val LOOP_THRESHOLD = 2
|
||||
private const val LOOP_WINDOW_MS = 10_000L
|
||||
}
|
||||
|
||||
/**
|
||||
* The allowlist of non-personal facts that go into a crash report. Built from
|
||||
* [Build] and the app's own [PackageInfo]; deliberately holds no identifiers.
|
||||
*/
|
||||
data class CrashContext(
|
||||
val appVersionName: String,
|
||||
val appVersionCode: Long,
|
||||
val sdkInt: Int,
|
||||
val androidRelease: String,
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val locale: String,
|
||||
) {
|
||||
companion object {
|
||||
fun from(context: Context): CrashContext {
|
||||
val pkg = runCatching {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
}.getOrNull()
|
||||
return CrashContext(
|
||||
appVersionName = pkg?.versionName ?: "?",
|
||||
appVersionCode = pkg?.let { PackageInfoCompat.getLongVersionCode(it) } ?: 0L,
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
androidRelease = Build.VERSION.RELEASE ?: "?",
|
||||
manufacturer = Build.MANUFACTURER ?: "?",
|
||||
model = Build.MODEL ?: "?",
|
||||
locale = Locale.getDefault().toLanguageTag(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a crash report from the [ctx] allowlist, the [throwable]'s full stack
|
||||
* trace, and the crash [nowMillis]. Pure (no Android, no I/O) so it is unit
|
||||
* tested. The leading marker doubles as the file's sanity check in
|
||||
* [CrashReporter.pendingReport].
|
||||
*/
|
||||
fun buildCrashReport(ctx: CrashContext, throwable: Throwable, nowMillis: Long): String {
|
||||
val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
|
||||
val time = runCatching {
|
||||
Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()).format(TIME_FORMAT)
|
||||
}.getOrDefault(nowMillis.toString())
|
||||
return buildString {
|
||||
appendLine("Calendula crash report")
|
||||
appendLine("App version: ${ctx.appVersionName} (${ctx.appVersionCode})")
|
||||
appendLine("Android: ${ctx.androidRelease} (API ${ctx.sdkInt})")
|
||||
appendLine("Device: ${ctx.manufacturer} ${ctx.model}")
|
||||
appendLine("Locale: ${ctx.locale}")
|
||||
appendLine("Time: $time")
|
||||
appendLine()
|
||||
appendLine("Stack trace:")
|
||||
append(trace)
|
||||
}
|
||||
}
|
||||
|
||||
private val TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
@@ -14,8 +14,12 @@ import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
||||
import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataSource
|
||||
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.ProviderReminderInstanceSource
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import javax.inject.Singleton
|
||||
@@ -42,9 +46,21 @@ abstract class DataBindModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindReminderAlertStore(
|
||||
impl: AndroidReminderAlertStore,
|
||||
): ReminderAlertStore
|
||||
abstract fun bindReminderInstanceSource(
|
||||
impl: ProviderReminderInstanceSource,
|
||||
): ReminderInstanceSource
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindContactSpecialDatesDataSource(
|
||||
impl: AndroidContactSpecialDatesDataSource,
|
||||
): ContactSpecialDatesDataSource
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindSpecialDatesCalendarSpec(
|
||||
impl: AndroidSpecialDatesCalendarSpec,
|
||||
): SpecialDatesCalendarSpec
|
||||
}
|
||||
|
||||
@Module
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.calendula.data.fonts
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import android.graphics.fonts.Font as PlatformFont
|
||||
|
||||
/**
|
||||
* Storage for user-loaded custom fonts (issue #19). A font a user picks via the
|
||||
* system file picker is copied into the app's private storage — one file per
|
||||
* [FontRole] — so the selection survives even if the original is later moved or
|
||||
* deleted, and never leaves the app. The chosen file is validated as a real font
|
||||
* before it replaces the previous one, so a bad pick can't wedge the app-wide
|
||||
* typography.
|
||||
*/
|
||||
object CustomFontStore {
|
||||
|
||||
private fun dir(context: Context): File = File(context.filesDir, "fonts")
|
||||
|
||||
/** The stored font file for [role] (may not exist yet). */
|
||||
fun file(context: Context, role: FontRole): File =
|
||||
File(dir(context), "${role.name.lowercase(Locale.ROOT)}.ttf")
|
||||
|
||||
fun exists(context: Context, role: FontRole): Boolean =
|
||||
file(context, role).let { it.exists() && it.length() > 0 }
|
||||
|
||||
/**
|
||||
* Copy [uri] into per-role storage, first validating that it parses as a
|
||||
* font. Returns true on success; on any failure the previous file is left
|
||||
* untouched and false is returned (the caller keeps the old selection).
|
||||
*/
|
||||
fun import(context: Context, role: FontRole, uri: Uri): Boolean {
|
||||
val target = file(context, role)
|
||||
target.parentFile?.mkdirs()
|
||||
val tmp = File(target.parentFile, "${role.name.lowercase(Locale.ROOT)}.tmp")
|
||||
return try {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
tmp.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: return false
|
||||
// Font.Builder throws IOException for anything that isn't a valid
|
||||
// font file (API 29+, which is our minSdk) — a cheap, reliable check.
|
||||
PlatformFont.Builder(tmp).build()
|
||||
if (target.exists() && !target.delete()) return false
|
||||
tmp.renameTo(target)
|
||||
} catch (_: Exception) {
|
||||
tmp.delete()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget the custom font for [role] (used when switching away from it). */
|
||||
fun clear(context: Context, role: FontRole) {
|
||||
file(context, role).delete()
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.ics
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
@@ -28,6 +29,36 @@ class IcsExporter @Inject constructor(
|
||||
} ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})")
|
||||
}
|
||||
|
||||
/**
|
||||
* Write [content] to [fileName] inside the persisted SAF tree [folder],
|
||||
* overwriting it if it already exists (the automatic-backup destination).
|
||||
* Requires a persisted write grant on [folder]. Throws on failure.
|
||||
*/
|
||||
fun writeToFolder(folder: Uri, fileName: String, content: String) {
|
||||
val dir = DocumentFile.fromTreeUri(context, folder)
|
||||
?: throw IOException("Backup folder is not accessible")
|
||||
if (!dir.exists() || !dir.canWrite()) {
|
||||
throw IOException("Backup folder is missing or not writable")
|
||||
}
|
||||
// Reuse the canonical file if present and clean up any "name (1).ics"
|
||||
// duplicates SAF may have created if two runs ever raced — so we always
|
||||
// converge on a single overwritten file.
|
||||
val base = fileName.substringBeforeLast('.')
|
||||
val ext = fileName.substringAfterLast('.', "")
|
||||
var target: DocumentFile? = null
|
||||
for (child in dir.listFiles()) {
|
||||
val name = child.name ?: continue
|
||||
when {
|
||||
name == fileName -> target = child
|
||||
name.startsWith("$base (") && name.endsWith(".$ext") -> child.delete()
|
||||
}
|
||||
}
|
||||
val file = target
|
||||
?: dir.createFile(MIME_CALENDAR, fileName)
|
||||
?: throw IOException("Could not create backup file in the chosen folder")
|
||||
writeDocument(file.uri, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage [content] in a private cache file and return a shareable content
|
||||
* Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to
|
||||
@@ -42,5 +73,6 @@ class IcsExporter @Inject constructor(
|
||||
|
||||
private companion object {
|
||||
const val SHARE_DIR = "shared_ics"
|
||||
const val MIME_CALENDAR = "text/calendar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,21 +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()))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 visibilityNoticePending: Flow<Boolean?> = store.data.map { prefs ->
|
||||
prefs[VISIBILITY_NOTICE_KEY]
|
||||
}
|
||||
|
||||
suspend fun setVisibilityNoticePending(pending: Boolean) {
|
||||
store.edit { prefs -> prefs[VISIBILITY_NOTICE_KEY] = pending }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,6 +95,17 @@ 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")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,57 +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.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
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
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)
|
||||
due.forEach(notifier::post)
|
||||
alertStore.markFired(due.map { it.alertId }, now)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Handles the "Snooze" and "Dismiss" actions on a reminder notification, plus
|
||||
* the internal re-show when a snooze elapses. All three are app-internal
|
||||
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
||||
* alarm), so the receiver is not exported.
|
||||
*
|
||||
* - **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 — 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() {
|
||||
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
@Inject lateinit var scheduler: ReminderSnoozeScheduler
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val alert = alertFrom(intent) ?: return
|
||||
when (intent.action) {
|
||||
ACTION_DISMISS -> notifier.cancel(alert)
|
||||
|
||||
ACTION_SNOOZE -> {
|
||||
// Cancel now so the notification doesn't linger until the alarm;
|
||||
// the snooze delay read is the only async work.
|
||||
notifier.cancel(alert)
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
val minutes = settingsPrefs.snoozeMinutes.first()
|
||||
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
|
||||
scheduler.schedule(alert, triggerAt)
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ACTION_SHOW -> {
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
|
||||
notifier.post(alert)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_SNOOZE = "de.jeanlucmakiola.calendula.reminders.SNOOZE"
|
||||
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
||||
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
|
||||
|
||||
/**
|
||||
* 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"
|
||||
private const val EXTRA_END = "end"
|
||||
private const val EXTRA_TITLE = "title"
|
||||
private const val EXTRA_LOCATION = "location"
|
||||
private const val EXTRA_ALL_DAY = "all_day"
|
||||
|
||||
/**
|
||||
* 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
|
||||
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)
|
||||
putExtra(EXTRA_END, alert.endMillis)
|
||||
putExtra(EXTRA_TITLE, alert.title)
|
||||
putExtra(EXTRA_LOCATION, alert.location)
|
||||
putExtra(EXTRA_ALL_DAY, alert.isAllDay)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.key.toInt() shl 3) + actionOffset
|
||||
}
|
||||
|
||||
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
|
||||
return ReminderAlert(
|
||||
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),
|
||||
endMillis = intent.getLongExtra(EXTRA_END, 0L),
|
||||
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
|
||||
location = intent.getStringExtra(EXTRA_LOCATION),
|
||||
isAllDay = intent.getBooleanExtra(EXTRA_ALL_DAY, false),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,112 +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 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),
|
||||
beginMillis = c.getLong(2),
|
||||
endMillis = c.getLong(3),
|
||||
title = c.getString(4).orEmpty(),
|
||||
location = c.getString(5)?.takeIf { it.isNotBlank() },
|
||||
isAllDay = c.getInt(6) == 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.BEGIN,
|
||||
CalendarContract.CalendarAlerts.END,
|
||||
CalendarContract.CalendarAlerts.TITLE,
|
||||
CalendarContract.CalendarAlerts.EVENT_LOCATION,
|
||||
CalendarContract.CalendarAlerts.ALL_DAY,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContentUris
|
||||
import android.provider.CalendarContract
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The read side of in-house reminder delivery: occurrences and their reminder
|
||||
* offsets, read from `Instances` and `Reminders` rather than `CalendarAlerts`,
|
||||
* which cannot be assumed to be written (#75).
|
||||
*
|
||||
* An interface so [ReminderScanner] can be exercised on the JVM.
|
||||
*/
|
||||
interface ReminderInstanceSource {
|
||||
|
||||
/** Occurrences overlapping `[fromMillis, toMillis]`, of switched-on calendars. */
|
||||
fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance>
|
||||
|
||||
/** `METHOD_ALERT` reminder offsets per event id, for the given events. */
|
||||
fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>>
|
||||
|
||||
/**
|
||||
* The largest `METHOD_ALERT` offset anywhere in the table, so the query
|
||||
* window can be stretched to cover it and a long-lead reminder is planned
|
||||
* before it comes due rather than firing late.
|
||||
*/
|
||||
fun longestReminderMinutes(): Int
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class ProviderReminderInstanceSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ReminderInstanceSource {
|
||||
|
||||
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
|
||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||
ContentUris.appendId(this, fromMillis)
|
||||
ContentUris.appendId(this, toMillis)
|
||||
}.build()
|
||||
// `visible` is the flag the app's one visibility model writes (#75).
|
||||
// The status clause mirrors CalendarDataSource.instances: NULL means
|
||||
// "normal", so a bare `!= CANCELED` would drop every ordinary event.
|
||||
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
|
||||
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})"
|
||||
return context.contentResolver.query(
|
||||
uri, OCCURRENCE_PROJECTION, selection, null, null,
|
||||
)?.use { c ->
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
add(
|
||||
ReminderEventInstance(
|
||||
eventId = c.getLong(0),
|
||||
calendarId = c.getLong(1),
|
||||
beginMillis = c.getLong(2),
|
||||
endMillis = if (c.isNull(3)) 0L else c.getLong(3),
|
||||
title = c.getString(4).orEmpty(),
|
||||
location = c.getString(5)?.takeIf { it.isNotBlank() },
|
||||
isAllDay = c.getInt(6) == 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
override fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>> {
|
||||
if (eventIds.isEmpty()) return emptyMap()
|
||||
val out = mutableMapOf<Long, MutableList<Int>>()
|
||||
// Batched: the ids go into the selection literally, and an unbounded
|
||||
// `IN (...)` would grow the SQL past what SQLite takes.
|
||||
eventIds.distinct().chunked(EVENT_ID_BATCH).forEach { batch ->
|
||||
context.contentResolver.query(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
REMINDER_PROJECTION,
|
||||
"${CalendarContract.Reminders.METHOD} = " +
|
||||
"${CalendarContract.Reminders.METHOD_ALERT} AND " +
|
||||
"${CalendarContract.Reminders.EVENT_ID} IN (${batch.joinToString(",")})",
|
||||
null,
|
||||
null,
|
||||
)?.use { c ->
|
||||
while (c.moveToNext()) {
|
||||
out.getOrPut(c.getLong(0)) { mutableListOf() } += c.getInt(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
override fun longestReminderMinutes(): Int = context.contentResolver.query(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Reminders.MINUTES),
|
||||
"${CalendarContract.Reminders.METHOD} = ${CalendarContract.Reminders.METHOD_ALERT}",
|
||||
null,
|
||||
// One row is enough: the provider passes the sort order to SQLite.
|
||||
"${CalendarContract.Reminders.MINUTES} DESC",
|
||||
)?.use { c -> if (c.moveToFirst()) c.getInt(0) else 0 } ?: 0
|
||||
|
||||
private companion object {
|
||||
const val EVENT_ID_BATCH = 50
|
||||
|
||||
val OCCURRENCE_PROJECTION = arrayOf(
|
||||
CalendarContract.Instances.EVENT_ID,
|
||||
CalendarContract.Instances.CALENDAR_ID,
|
||||
CalendarContract.Instances.BEGIN,
|
||||
CalendarContract.Instances.END,
|
||||
CalendarContract.Instances.TITLE,
|
||||
CalendarContract.Instances.EVENT_LOCATION,
|
||||
CalendarContract.Instances.ALL_DAY,
|
||||
)
|
||||
|
||||
val REMINDER_PROJECTION = arrayOf(
|
||||
CalendarContract.Reminders.EVENT_ID,
|
||||
CalendarContract.Reminders.MINUTES,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The backstop under the alarm: a daily scan that runs whether or not the alarm
|
||||
* survived. Finds nothing to do in the steady state; it exists for the device
|
||||
* that quietly drops the alarm without a reboot to announce it (#75).
|
||||
*/
|
||||
object ReminderMaintenanceScheduler {
|
||||
|
||||
private const val WORK_NAME = "reminder-scan-maintenance"
|
||||
|
||||
/** Enqueue the daily backstop; idempotent, so every launch may call it. */
|
||||
fun apply(context: Context) {
|
||||
val request = PeriodicWorkRequestBuilder<ReminderMaintenanceWorker>(1, TimeUnit.DAYS)
|
||||
// The launch scan covers now; let the first periodic run wait.
|
||||
.setInitialDelay(1, TimeUnit.DAYS)
|
||||
.build()
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||
}
|
||||
}
|
||||
|
||||
class ReminderMaintenanceWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Deps {
|
||||
fun reminderScanner(): ReminderScanner
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result = try {
|
||||
EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||
.reminderScanner()
|
||||
.scan()
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
// The scan swallows its own failures, so anything reaching here is the
|
||||
// entry point itself — a retry will not mend it.
|
||||
Log.w(TAG, "Reminder maintenance scan failed", e)
|
||||
Result.success()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderMaintenance"
|
||||
}
|
||||
}
|
||||
@@ -14,21 +14,35 @@ import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Posts one notification per due reminder alert on a dedicated channel.
|
||||
* Tapping opens the event's detail screen; the tag is the alert id, so a
|
||||
* re-broadcast of an alert we couldn't mark fired replaces its notification
|
||||
* silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of
|
||||
* duplicating it.
|
||||
* Posts one notification per due reminder on a dedicated channel. Tapping opens
|
||||
* the event's detail screen.
|
||||
*
|
||||
* The tag is the reminder's stable key, so a scan that posts the same reminder
|
||||
* again — a catch-up pass overlapping the alarm that already fired — replaces
|
||||
* its notification silently ([NotificationCompat.Builder.setOnlyAlertOnce])
|
||||
* instead of stacking a second one.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderNotifier @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val calendarPrefs: CalendarPrefs,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
) {
|
||||
|
||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||
@@ -39,15 +53,43 @@ class ReminderNotifier @Inject constructor(
|
||||
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
fun post(alert: ReminderAlert) {
|
||||
/**
|
||||
* The single choke point for "this calendar is switched off", covering the
|
||||
* two paths that reach [post] around the scan's own filter: a snooze armed
|
||||
* before the switch-off, and a read-only install whose switch lives in
|
||||
* [CalendarPrefs].
|
||||
*/
|
||||
private suspend fun isSilenced(calendarId: Long): Boolean =
|
||||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
||||
calendarDataSource.isCalendarVisible(calendarId) == false
|
||||
|
||||
/**
|
||||
* Post [alert], unless its calendar is switched off. Returns whether the
|
||||
* notification was put up, which the snooze re-show path uses to tell a
|
||||
* silenced reminder from a delivered one.
|
||||
*/
|
||||
suspend fun post(alert: ReminderAlert): Boolean {
|
||||
if (isSilenced(alert.calendarId)) return false
|
||||
ensureChannel()
|
||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
val zone = ZoneId.systemDefault()
|
||||
val locale = Locale.getDefault()
|
||||
// resolveFirstDay yields a kotlinx.datetime day; bridge it to java.time by
|
||||
// its shared ISO number (1..7) for the date math in reminderTimeText.
|
||||
val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber)
|
||||
val time = reminderTimeText(
|
||||
beginMillis = alert.beginMillis,
|
||||
endMillis = alert.endMillis,
|
||||
isAllDay = alert.isAllDay,
|
||||
zone = ZoneId.systemDefault(),
|
||||
locale = Locale.getDefault(),
|
||||
zone = zone,
|
||||
locale = locale,
|
||||
is24Hour = is24Hour,
|
||||
today = Instant.now().atZone(zone).toLocalDate(),
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
||||
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
||||
)
|
||||
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
@@ -61,19 +103,47 @@ class ReminderNotifier @Inject constructor(
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(detailIntent(alert))
|
||||
.addAction(
|
||||
R.drawable.ic_notification_snooze,
|
||||
context.getString(R.string.reminder_action_snooze),
|
||||
actionIntent(alert, ReminderActionReceiver.ACTION_SNOOZE),
|
||||
)
|
||||
.addAction(
|
||||
R.drawable.ic_notification_dismiss,
|
||||
context.getString(R.string.reminder_action_dismiss),
|
||||
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
|
||||
)
|
||||
.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.key.toString(), NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
ReminderActionReceiver.requestCode(alert, action),
|
||||
ReminderActionReceiver.intent(context, action, alert),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
/* requestCode = */ alert.alertId.toInt(),
|
||||
// Shares the per-(alert, action) request-code scheme with the buttons.
|
||||
/* requestCode = */ ReminderActionReceiver.requestCode(
|
||||
alert, ReminderActionReceiver.ACTION_OPEN,
|
||||
),
|
||||
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* One pass of in-house reminder delivery: read what is planned, post what has
|
||||
* come due, and arm the next wake-up. Every trigger runs the same [scan], and
|
||||
* re-running is always safe — the watermark in [ReminderStatePrefs] decides what
|
||||
* is owed, not the trigger.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderScanner @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val source: ReminderInstanceSource,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
private val notifier: ReminderNotifier,
|
||||
private val alarms: ReminderAlarmScheduler,
|
||||
private val state: ReminderStatePrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: kotlinx.coroutines.CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
// Triggers overlap freely (an alarm during a burst of edits); serialize so
|
||||
// two passes can't both read the same watermark and post the same reminder.
|
||||
private val scanLock = Mutex()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + io)
|
||||
private val providerChanges = MutableSharedFlow<Unit>(
|
||||
replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
private var watching = false
|
||||
|
||||
suspend fun scan() = withContext(io) {
|
||||
scanLock.withLock {
|
||||
try {
|
||||
runScan()
|
||||
} catch (e: SecurityException) {
|
||||
// Permission revoked mid-flight; the next grant re-scans.
|
||||
Log.w(TAG, "Reminder scan lacks the calendar permission", e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Reminder scan failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runScan() {
|
||||
val now = System.currentTimeMillis()
|
||||
if (!hasReadCalendar()) return
|
||||
|
||||
if (!settingsPrefs.remindersEnabled.first()) {
|
||||
// Reminders off: drop the wake-up, but keep the watermark moving
|
||||
// so switching them back on doesn't replay the backlog.
|
||||
alarms.cancelScan()
|
||||
state.setLastScanMillis(now)
|
||||
return
|
||||
}
|
||||
|
||||
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
|
||||
// Reach into the past too: an all-day "at time of event" encodes to a
|
||||
// negative offset, and a catch-up pass needs the occurrences it missed.
|
||||
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
|
||||
val planned = planReminders(
|
||||
instances = occurrences,
|
||||
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId }),
|
||||
zone = ZoneId.systemDefault(),
|
||||
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
|
||||
)
|
||||
|
||||
val schedule = scheduleReminders(
|
||||
planned = planned,
|
||||
lastFiredMillis = reminderWatermark(state.lastScanMillis(), now),
|
||||
nowMillis = now,
|
||||
horizonMillis = now + MAX_ALARM_INTERVAL_MILLIS,
|
||||
)
|
||||
|
||||
if (notifier.canPost()) {
|
||||
schedule.due.forEach { notifier.post(it.toAlert()) }
|
||||
}
|
||||
// Advance even when nothing could be posted, so muting notifications
|
||||
// doesn't build a backlog.
|
||||
state.setLastScanMillis(now)
|
||||
alarms.scheduleScan(schedule.nextAlarmMillis)
|
||||
}
|
||||
|
||||
private fun hasReadCalendar(): Boolean = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/**
|
||||
* Re-scan when the provider changes, so a saved or deleted event re-arms the
|
||||
* alarm at once. Debounced, since a single save lands as several
|
||||
* notifications. Process-lifetime only; other triggers cover the rest.
|
||||
*/
|
||||
fun startWatchingProvider() {
|
||||
if (watching) return
|
||||
watching = true
|
||||
providerChanges
|
||||
.debounce(PROVIDER_CHANGE_DEBOUNCE_MILLIS)
|
||||
.onEach { scan() }
|
||||
.launchIn(scope)
|
||||
calendarDataSource.registerChangeListener { providerChanges.tryEmit(Unit) }
|
||||
}
|
||||
|
||||
/** Fire-and-forget scan for callers that are not in a coroutine already. */
|
||||
fun scanInBackground() {
|
||||
scope.launch(start = CoroutineStart.DEFAULT) { scan() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderScanner"
|
||||
|
||||
/**
|
||||
* How far ahead occurrences are read. Stretched further by the longest
|
||||
* reminder offset in the table, so this is only the floor.
|
||||
*/
|
||||
const val LOOKAHEAD_MILLIS = 7L * 24 * 60 * 60 * 1000
|
||||
|
||||
/** How far back to look for occurrences that may still owe a reminder. */
|
||||
const val PAST_WINDOW_MILLIS = 24L * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Never wait longer than a day for the next pass: it rolls the lookahead
|
||||
* window forward and re-arms an alarm the system may have dropped.
|
||||
*/
|
||||
const val MAX_ALARM_INTERVAL_MILLIS = 24L * 60 * 60 * 1000
|
||||
|
||||
const val PROVIDER_CHANGE_DEBOUNCE_MILLIS = 2_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Every out-of-process reason to re-run a reminder scan: our own [ACTION_SCAN]
|
||||
* alarm, boot and package-replaced (both wipe pending alarms), and time or
|
||||
* timezone changes (both move reminders relative to the armed alarm). All do the
|
||||
* same thing, since [ReminderScanner.scan] is idempotent.
|
||||
*
|
||||
* Exported for the system broadcasts; an early scan triggered by another app is
|
||||
* harmless.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ReminderScheduleReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var scanner: ReminderScanner
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
// Checked despite every action doing the same thing: the receiver is
|
||||
// exported and the broadcasts it takes are protected, so any other
|
||||
// action did not come from where it claims to.
|
||||
if (intent.action !in HANDLED_ACTIONS) return
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
scanner.scan()
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_SCAN = "de.jeanlucmakiola.calendula.reminders.SCAN"
|
||||
|
||||
private val HANDLED_ACTIONS = setOf(
|
||||
ACTION_SCAN,
|
||||
Intent.ACTION_BOOT_COMPLETED,
|
||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||
Intent.ACTION_TIME_CHANGED,
|
||||
Intent.ACTION_TIMEZONE_CHANGED,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.content.getSystemService
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
fun schedule(alert: ReminderAlert, triggerAtMillis: Long) {
|
||||
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_SHOW),
|
||||
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
if (canScheduleExact(alarmManager)) {
|
||||
alarmManager.setExactAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
} else {
|
||||
// Exact alarms revoked (API 31–32); an inexact wake is the best
|
||||
// available without nagging for SCHEDULE_EXACT_ALARM.
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True on API < 31 (no restriction), and on 31+ when the exact-alarm
|
||||
* capability is held — auto-granted via `USE_EXACT_ALARM` on API 33+
|
||||
* (Calendula is a calendar app), user-revocable on 31–32.
|
||||
*/
|
||||
private fun canScheduleExact(alarmManager: AlarmManager): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
|
||||
}
|
||||
@@ -1,22 +1,37 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The one line of time context in a reminder notification. Pure so it can be
|
||||
* JVM-tested:
|
||||
* JVM-tested.
|
||||
*
|
||||
* - timed, same day: "09:30 – 10:00"
|
||||
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time)
|
||||
* Timed events that fall on a day other than [today] are prefixed with that
|
||||
* day, so a reminder fired ahead of time no longer reads as if the event were
|
||||
* today (issue #46). The prefix prefers natural language and stays short:
|
||||
*
|
||||
* - today: "09:30 – 10:00" (no prefix)
|
||||
* - tomorrow / yesterday: "Tomorrow, 09:30 – 10:00" ([tomorrowLabel] / [yesterdayLabel])
|
||||
* - elsewhere this week: "Thu, 09:30 – 10:00" (localized short weekday)
|
||||
* - further out: "16 Jul, 09:30 – 10:00" (medium date — a weekday
|
||||
* alone would be ambiguous)
|
||||
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
|
||||
* already unambiguous)
|
||||
* - all-day, one day: "11 Jun 2026"
|
||||
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
||||
*
|
||||
* All-day instances store UTC midnights with an exclusive end, so they are
|
||||
* All-day instances already carry an explicit date, so they never gain a
|
||||
* relative prefix. They store UTC midnights with an exclusive end, so they are
|
||||
* read in UTC and the end day is the last *covered* day.
|
||||
*/
|
||||
fun reminderTimeText(
|
||||
@@ -25,6 +40,11 @@ fun reminderTimeText(
|
||||
isAllDay: Boolean,
|
||||
zone: ZoneId,
|
||||
locale: Locale,
|
||||
is24Hour: Boolean,
|
||||
today: LocalDate,
|
||||
firstDayOfWeek: DayOfWeek,
|
||||
tomorrowLabel: String,
|
||||
yesterdayLabel: String,
|
||||
): String {
|
||||
if (isAllDay) {
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
@@ -40,17 +60,71 @@ fun reminderTimeText(
|
||||
}
|
||||
}
|
||||
|
||||
val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
||||
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
||||
return if (begin.toLocalDate() == end.toLocalDate()) {
|
||||
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||
val range = timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||
val prefix = relativeDayPrefix(
|
||||
day = begin.toLocalDate(),
|
||||
today = today,
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
locale = locale,
|
||||
dateFormat = dateFormat,
|
||||
tomorrowLabel = tomorrowLabel,
|
||||
yesterdayLabel = yesterdayLabel,
|
||||
)
|
||||
if (prefix == null) range else "$prefix, $range"
|
||||
} else {
|
||||
val dateTimeFormat = DateTimeFormatter
|
||||
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
|
||||
.withLocale(locale)
|
||||
dateTimeFormat.format(begin) + RANGE + dateTimeFormat.format(end)
|
||||
// Cross-day: medium date + the chosen short time, joined per side. Built
|
||||
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
|
||||
// applies to the time portion too. The explicit dates already say which
|
||||
// day, so no relative prefix is layered on top.
|
||||
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
|
||||
dateTime(begin) + RANGE + dateTime(end)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A short label for [day] relative to [today], or `null` when it *is* today (the
|
||||
* common case, which needs no prefix). Weekday names are used only within the
|
||||
* current week — a "next Wednesday" would be indistinguishable from this one, so
|
||||
* anything past this week falls back to the exact date.
|
||||
*/
|
||||
private fun relativeDayPrefix(
|
||||
day: LocalDate,
|
||||
today: LocalDate,
|
||||
firstDayOfWeek: DayOfWeek,
|
||||
locale: Locale,
|
||||
dateFormat: DateTimeFormatter,
|
||||
tomorrowLabel: String,
|
||||
yesterdayLabel: String,
|
||||
): String? = when (ChronoUnit.DAYS.between(today, day)) {
|
||||
0L -> null
|
||||
1L -> tomorrowLabel
|
||||
-1L -> yesterdayLabel
|
||||
else -> if (isSameWeek(day, today, firstDayOfWeek)) {
|
||||
day.dayOfWeek.getDisplayName(TextStyle.SHORT, locale)
|
||||
} else {
|
||||
dateFormat.format(day)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [day] and [today] share the same week. The week boundary honours the
|
||||
* user's *week starts on* setting (already resolved to a concrete [firstDayOfWeek],
|
||||
* with [firstDayOfWeek] falling back to the locale default upstream).
|
||||
*/
|
||||
private fun isSameWeek(day: LocalDate, today: LocalDate, firstDayOfWeek: DayOfWeek): Boolean {
|
||||
val startOfWeek = today.previousOrSame(firstDayOfWeek)
|
||||
return !day.isBefore(startOfWeek) && day.isBefore(startOfWeek.plusWeeks(1))
|
||||
}
|
||||
|
||||
/** The most recent [target] on or before this date (this date itself when it matches). */
|
||||
private fun LocalDate.previousOrSame(target: DayOfWeek): LocalDate {
|
||||
val backtrack = (dayOfWeek.value - target.value + 7) % 7
|
||||
return minusDays(backtrack.toLong())
|
||||
}
|
||||
|
||||
private const val RANGE = " – "
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
/**
|
||||
* The ways a calendar can behave unlike a plain, writable one — each a reason it
|
||||
* is missing from the event and import pickers (#76).
|
||||
*/
|
||||
enum class CalendarStateLabel {
|
||||
/**
|
||||
* A special-dates mirror the app fills from contacts. Writable and visible,
|
||||
* yet no event target: anything authored here is deleted by the next sync.
|
||||
*/
|
||||
MANAGED,
|
||||
|
||||
/** Contents can't be modified: a WebCal subscription, a read-only share. */
|
||||
READ_ONLY,
|
||||
|
||||
/** The account holds the events, but this device isn't syncing them down. */
|
||||
NOT_SYNCED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account keeps this calendar's events off the device
|
||||
* (`Calendars.SYNC_EVENTS = 0`) — empty by construction. Device-local calendars
|
||||
* are excluded: nothing syncs them by definition, and one from another app can
|
||||
* hold real events at `sync_events = 0`.
|
||||
*/
|
||||
val CalendarSource.isNotSynced: Boolean
|
||||
get() = !syncsEvents && !isLocal
|
||||
|
||||
/**
|
||||
* Whether a visibility switch on this calendar can change anything the user
|
||||
* would see — it can't for a non-syncing one, with no events on the device.
|
||||
*/
|
||||
val CalendarSource.hasVisibilitySwitch: Boolean
|
||||
get() = !isNotSynced
|
||||
|
||||
/**
|
||||
* Whether this calendar can be offered as a target for a new or imported event.
|
||||
* The one predicate behind both pickers, so the states [CalendarStateLabel]
|
||||
* names on a manager row are exactly the states that keep a calendar out of them
|
||||
* (#76). An event already living in an excluded calendar keeps it; the editor
|
||||
* adds that calendar back to its picker.
|
||||
*/
|
||||
val CalendarSource.isEventTarget: Boolean
|
||||
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
|
||||
|
||||
/** Every state worth naming on this calendar's row, in reading order. */
|
||||
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
||||
if (isManaged) add(CalendarStateLabel.MANAGED)
|
||||
if (!canModifyContents) add(CalendarStateLabel.READ_ONLY)
|
||||
if (isNotSynced) add(CalendarStateLabel.NOT_SYNCED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar-manager order within one group: the ones you can actually act on
|
||||
* first, the non-syncing ones after them. Stable otherwise, so the provider's
|
||||
* display-name ordering survives.
|
||||
*/
|
||||
fun List<CalendarSource>.orderedForManager(): List<CalendarSource> =
|
||||
sortedBy { it.isNotSynced }
|
||||
@@ -0,0 +1,48 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
/**
|
||||
* The `Calendars.VISIBLE` writes that flush the app's pending "switched off"
|
||||
* set into the provider, plus the ids that need no write at all.
|
||||
*/
|
||||
data class CalendarVisibilityPlan(
|
||||
val hide: Set<Long> = emptySet(),
|
||||
val settled: Set<Long> = emptySet(),
|
||||
) {
|
||||
val isEmpty: Boolean get() = hide.isEmpty() && settled.isEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile [pendingDisabledIds] — switch-offs the app could not write, plus
|
||||
* what the retired app-local visibility model left behind (#75) — against the
|
||||
* calendars actually on the device.
|
||||
*
|
||||
* Only ever *hides*: switching a system-hidden calendar back on would un-hide it
|
||||
* in every other calendar app too. Calendula follows the flag and explains
|
||||
* itself once (see [hasSystemHiddenCalendars]).
|
||||
*
|
||||
* [CalendarVisibilityPlan.settled] carries the ids needing no write — already
|
||||
* hidden, or gone from the device.
|
||||
*/
|
||||
fun calendarVisibilityPlan(
|
||||
calendars: List<CalendarSource>,
|
||||
pendingDisabledIds: Set<Long>,
|
||||
): CalendarVisibilityPlan {
|
||||
val byId = calendars.associateBy { it.id }
|
||||
val hide = mutableSetOf<Long>()
|
||||
val settled = mutableSetOf<Long>()
|
||||
for (id in pendingDisabledIds) {
|
||||
val calendar = byId[id]
|
||||
// Gone from the device, or already invisible — nothing to write.
|
||||
if (calendar != null && calendar.isVisibleInSystem) hide += id else settled += id
|
||||
}
|
||||
return CalendarVisibilityPlan(hide = hide, settled = settled)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any calendar is switched off at system level without Calendula having
|
||||
* asked for it — the condition the one-time notice explains.
|
||||
*/
|
||||
fun hasSystemHiddenCalendars(
|
||||
calendars: List<CalendarSource>,
|
||||
pendingDisabledIds: Set<Long>,
|
||||
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
|
||||
@@ -0,0 +1,112 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
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.
|
||||
*
|
||||
* Sync adapters differ wildly in what they publish: Google exposes a
|
||||
* hand-picked two-dozen set, while CalDAV adapters (DAVx5) dump all ~147 CSS3
|
||||
* named colours — including exact-value aliases (aqua/cyan, the gray/grey
|
||||
* spelling pairs) and dozens of visually indistinguishable whites and grays
|
||||
* (#22).
|
||||
*
|
||||
* Crucially, curation runs against the colour the picker actually *paints*, not
|
||||
* the raw provider value — 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 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.
|
||||
*
|
||||
* Every surviving option keeps its provider [EventColorOption.key], so a pick
|
||||
* still round-trips through sync.
|
||||
*/
|
||||
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||
val painted = sortedBy { it.key }
|
||||
.distinctBy { paintedArgb(it.argb) }
|
||||
.map { it to oklchOf(paintedArgb(it.argb)) }
|
||||
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
|
||||
painted
|
||||
} else {
|
||||
thin(painted.filter { (_, painted) -> painted.chroma > 0f })
|
||||
}
|
||||
return orderAroundWheel(kept).map { (option, _) -> option }
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders swatches continuously around the (painted) hue wheel, then cuts the
|
||||
* circle at its widest angular gap so the single seam lands in empty space
|
||||
* instead of mid-family. Saturation breaks ties, vivid first.
|
||||
*/
|
||||
private fun orderAroundWheel(
|
||||
swatches: List<Pair<EventColorOption, Oklch>>,
|
||||
): List<Pair<EventColorOption, Oklch>> {
|
||||
if (swatches.size < 2) return swatches
|
||||
val byHue = swatches.sortedWith(
|
||||
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 = 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) {
|
||||
widestGap = gap
|
||||
cutAfter = i
|
||||
}
|
||||
}
|
||||
return byHue.subList(cutAfter + 1, byHue.size) + byHue.subList(0, cutAfter + 1)
|
||||
}
|
||||
|
||||
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
||||
private fun thin(
|
||||
swatches: List<Pair<EventColorOption, Oklch>>,
|
||||
): List<Pair<EventColorOption, Oklch>> {
|
||||
val byVividness = swatches
|
||||
.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.distanceTo(candidate.second) < MIN_DISTANCE }) kept += candidate
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
/** 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 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
|
||||
@@ -4,13 +4,14 @@ import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* User input for creating an event (and, from v1.3, editing one). Times are
|
||||
* wall-clock values in the device zone; the data layer translates them to
|
||||
* provider millis (all-day events normalise to UTC midnights there).
|
||||
* wall-clock values in [timezone]; the data layer translates them to provider
|
||||
* millis (all-day events normalise to UTC midnights there).
|
||||
*/
|
||||
data class EventForm(
|
||||
val calendarId: Long?,
|
||||
@@ -18,6 +19,23 @@ data class EventForm(
|
||||
val isAllDay: Boolean = false,
|
||||
val start: LocalDateTime,
|
||||
val end: LocalDateTime,
|
||||
/**
|
||||
* The zone [start]/[end] are wall-clock values in, or null to follow the
|
||||
* device — null is not "no zone", it is "whichever zone the device is in
|
||||
* when this is saved", which is what an event authored and lived in one
|
||||
* place wants. The data layer resolves it at write time and always stamps a
|
||||
* concrete `EVENT_TIMEZONE`.
|
||||
*
|
||||
* A non-null value pins the event to a zone regardless of where the device
|
||||
* is, so it keeps tracking that zone's offset across DST. [toEditForm] only
|
||||
* sets it when the stored zone differs from the device's, so merely opening
|
||||
* a local event never reveals the field — and re-opening a pinned one in
|
||||
* another zone round-trips it rather than silently re-anchoring it.
|
||||
*
|
||||
* Always null for all-day events: those are date-anchored, not zone-anchored
|
||||
* (see [EventFormField.Timezone] and the data layer's UTC-midnight rule).
|
||||
*/
|
||||
val timezone: String? = null,
|
||||
val location: String = "",
|
||||
val description: String = "",
|
||||
/** Reminder lead times in minutes before the start, deduplicated. */
|
||||
@@ -41,20 +59,50 @@ data class EventForm(
|
||||
*/
|
||||
val colorKey: String? = null,
|
||||
val color: Int? = null,
|
||||
/**
|
||||
* Guests the user has added/kept on the event. Calendula only writes these
|
||||
* `Attendees` rows — it has no INTERNET and never sends an invitation
|
||||
* itself; whether a guest is notified is decided downstream by the
|
||||
* calendar's backend (local: no one; CalDAV/Google: the server/account).
|
||||
* Read-only rows the form doesn't model (the organizer, resources) are
|
||||
* preserved by the data layer, not carried here.
|
||||
*/
|
||||
val attendees: List<EventAttendee> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One editable guest: the user controls the [email] (the identity we dedup and
|
||||
* write on), an optional display [name], and whether they're [optional] rather
|
||||
* than required. Response status and the organizer/resource distinction are not
|
||||
* user-editable, so they aren't modelled here.
|
||||
*/
|
||||
data class EventAttendee(
|
||||
val email: String,
|
||||
val name: String = "",
|
||||
val optional: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* The form's optional sections. Which ones show by default is a user setting;
|
||||
* the rest unfold behind a "more fields" button.
|
||||
* the rest unfold behind a "more fields" button. Declaration order is the order
|
||||
* they're offered in, so a new constant goes where it belongs on the form, not
|
||||
* at the end.
|
||||
*/
|
||||
enum class EventFormField {
|
||||
Location,
|
||||
Description,
|
||||
/**
|
||||
* Pins the event's wall-clock times to a zone. Offered right after the time
|
||||
* fields it qualifies, and suppressed entirely for all-day events, whose
|
||||
* dates are deliberately zone-free.
|
||||
*/
|
||||
Timezone,
|
||||
Reminders,
|
||||
Recurrence,
|
||||
Availability,
|
||||
Visibility,
|
||||
Color,
|
||||
Attendees,
|
||||
}
|
||||
|
||||
enum class EventFormProblem {
|
||||
@@ -78,8 +126,25 @@ enum class EventFormProblem {
|
||||
* All-day provider times are UTC midnights with an exclusive end; the form
|
||||
* shows the last covered day and keeps placeholder wall-clock times in case
|
||||
* the user switches the event to timed.
|
||||
*
|
||||
* A timed event stored in a zone other than [zone] is prefilled *in its own
|
||||
* zone* and keeps it pinned, so the wall-clock the form shows is the one the
|
||||
* event means ("the New York 09:00 call") and a later save re-anchors it to the
|
||||
* same zone rather than the device's.
|
||||
*/
|
||||
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
|
||||
// All-day events are date-anchored and carry a nominal "UTC" that is an
|
||||
// anchor, not a location, so they never pin a zone.
|
||||
val pinnedZone = if (instance.isAllDay) {
|
||||
null
|
||||
} else {
|
||||
eventTimezone
|
||||
?.takeIf { it != zone.id }
|
||||
// An unparseable id (a malformed sync row) can't be honoured or
|
||||
// shown; fall back to the device zone rather than failing the open.
|
||||
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
|
||||
}
|
||||
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
|
||||
val (start, end) = if (instance.isAllDay) {
|
||||
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
||||
.toLocalDateTime(TimeZone.UTC).date
|
||||
@@ -88,8 +153,8 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||
} else {
|
||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to
|
||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone)
|
||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
|
||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = instance.calendarId,
|
||||
@@ -97,6 +162,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
isAllDay = instance.isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
timezone = pinnedZone,
|
||||
location = instance.location.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
||||
@@ -108,6 +174,24 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
// calendar's colour.
|
||||
colorKey = eventColorKey,
|
||||
color = eventColor,
|
||||
// Only editable guests ride in the form: drop the organizer and
|
||||
// resource rows (not user-editable) and any without an email (we key
|
||||
// edits and dedup on the address). The data layer preserves the rows we
|
||||
// don't carry here, so they're never clobbered on save.
|
||||
attendees = attendees
|
||||
.filter {
|
||||
it.relationship != AttendeeRelationship.Organizer &&
|
||||
it.type != AttendeeType.Resource
|
||||
}
|
||||
.mapNotNull { a ->
|
||||
a.email?.takeIf { it.isNotBlank() }?.let { email ->
|
||||
EventAttendee(
|
||||
email = email,
|
||||
name = a.name,
|
||||
optional = a.type == AttendeeType.Optional,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,9 +201,10 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
* while the form was open. The raw row times ride along because
|
||||
* [toEditForm] derives the form's times from the *tapped occurrence*, so
|
||||
* re-deriving with the same occurrence would mask an externally moved
|
||||
* event. Not covered (the form can't write them, and the dirty-checked
|
||||
* write can't clobber them): attendees, status, the user's own response,
|
||||
* reminder methods, and a recurring event's duration.
|
||||
* event. Guests are covered (the form writes editable attendees), so an
|
||||
* external attendee change now also trips the conflict check. Still not
|
||||
* covered: status, the user's own response, reminder methods, the
|
||||
* organizer/resource rows, and a recurring event's duration.
|
||||
*/
|
||||
data class EditSnapshot(
|
||||
val form: EventForm,
|
||||
@@ -135,6 +220,23 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
||||
rowEnd = instance.end,
|
||||
)
|
||||
|
||||
/**
|
||||
* The form's times as they land in [target] — what a pinned event's wall-clock
|
||||
* actually means where the user is standing. Null when there's nothing to
|
||||
* disambiguate: an unpinned event (already in [target]), one pinned to [target]
|
||||
* itself, an all-day event (no zone), or an unparseable pinned zone.
|
||||
*
|
||||
* The form edits a pinned event in its own zone, so this is what lets the UI
|
||||
* show the other side of the pair rather than making the user do the arithmetic.
|
||||
*/
|
||||
fun EventForm.timesIn(target: TimeZone): Pair<LocalDateTime, LocalDateTime>? {
|
||||
if (isAllDay) return null
|
||||
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return null
|
||||
if (pinned.id == target.id) return null
|
||||
return start.toInstant(pinned).toLocalDateTime(target) to
|
||||
end.toInstant(pinned).toLocalDateTime(target)
|
||||
}
|
||||
|
||||
/**
|
||||
* The optional sections that hold a value in [form] — when editing, these
|
||||
* must be visible regardless of the user's default-fields setting, or the
|
||||
@@ -143,11 +245,13 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
||||
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||
if (location.isNotBlank()) add(EventFormField.Location)
|
||||
if (description.isNotBlank()) add(EventFormField.Description)
|
||||
if (timezone != null) add(EventFormField.Timezone)
|
||||
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
||||
if (rrule != null) add(EventFormField.Recurrence)
|
||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
||||
if (colorKey != null || color != null) add(EventFormField.Color)
|
||||
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
|
||||
}
|
||||
|
||||
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
/**
|
||||
* The two Material 3 typeface roles a user can set independently (issue #19):
|
||||
* [BRAND] drives the display/headline styles (expression), [PLAIN] the
|
||||
* title/body/label styles (readability). Each defaults to the system typeface.
|
||||
*/
|
||||
enum class FontRole { BRAND, PLAIN }
|
||||
@@ -0,0 +1,78 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Build a prefilled [EventForm] from an `ACTION_INSERT` intent's extras (issue
|
||||
* #30). External apps and widgets (e.g. the Todo Agenda widget) launch the
|
||||
* calendar this way to create a new event, passing the fields as
|
||||
* [android.provider.CalendarContract] extras. Any field the intent omits falls
|
||||
* back to the same defaults the in-app "new event" uses — a timed start at the
|
||||
* next full hour, and a 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.
|
||||
*
|
||||
* Pure (no Android types) so it is unit-testable; the intent parsing that reads
|
||||
* the extras lives in `MainActivity.insertFormOrNull`.
|
||||
*/
|
||||
fun buildInsertEventForm(
|
||||
beginMillis: Long?,
|
||||
endMillis: Long?,
|
||||
isAllDay: Boolean,
|
||||
title: String?,
|
||||
description: String?,
|
||||
location: String?,
|
||||
rrule: String?,
|
||||
zone: TimeZone,
|
||||
now: Instant,
|
||||
): EventForm {
|
||||
val (start, end) = if (isAllDay) {
|
||||
// All-day provider times are UTC midnights with an exclusive end; show
|
||||
// the last covered day and keep placeholder wall-clock times in case the
|
||||
// user switches the event to timed (mirrors EventDetail.toEditForm).
|
||||
val startDate = beginMillis
|
||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||
?: now.toLocalDateTime(zone).date
|
||||
val endDate = endMillis
|
||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
|
||||
?: startDate
|
||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||
} else {
|
||||
val startTime = beginMillis
|
||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
|
||||
?: nextFullHour(now, zone)
|
||||
val endTime = endMillis
|
||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
|
||||
?.takeIf { it >= startTime }
|
||||
?: (startTime.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
||||
startTime to endTime
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = null,
|
||||
title = title.orEmpty(),
|
||||
isAllDay = isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
location = location.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
// Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:"
|
||||
// some callers include.
|
||||
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun nextFullHour(now: Instant, zone: TimeZone): LocalDateTime {
|
||||
val hourMillis = 3_600_000L
|
||||
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
|
||||
return Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
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
|
||||
@@ -26,6 +35,21 @@ data class CalendarSource(
|
||||
* owns for its own calendars). Always null for synced calendars.
|
||||
*/
|
||||
val description: String? = null,
|
||||
/**
|
||||
* A special-dates mirror calendar the app manages (birthdays/anniversaries
|
||||
* from contacts). Its events' title/date/recurrence are owned by the sync,
|
||||
* so it's hidden from the new-event calendar picker and its events lock those
|
||||
* fields in the editor. Recognised by a durable provider marker, so it holds
|
||||
* 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(
|
||||
@@ -40,6 +64,39 @@ data class EventInstance(
|
||||
val location: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* is *not* considered ended. All-day events end at the exclusive next-midnight,
|
||||
* so they only count as ended once their day is fully over.
|
||||
*/
|
||||
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||
|
||||
/**
|
||||
* The zone this event's calendar dates live in: the device [zone] for timed
|
||||
* events, UTC for all-day ones, whose midnights would otherwise shift day
|
||||
* boundaries (#65, #82). Every surface naming an all-day date goes through here.
|
||||
*/
|
||||
fun EventInstance.dateZone(zone: TimeZone): TimeZone =
|
||||
if (isAllDay) TimeZone.UTC else zone
|
||||
|
||||
/** The first calendar day this event occupies. */
|
||||
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
|
||||
start.toLocalDateTime(dateZone(zone)).date
|
||||
|
||||
/**
|
||||
* The last calendar day this event occupies. An event ending exactly at midnight
|
||||
* does not reach into that day, so resolve just before [EventInstance.end].
|
||||
*/
|
||||
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
||||
val lastInstant = if (end > start) end - 1.milliseconds else start
|
||||
return lastInstant.toLocalDateTime(dateZone(zone)).date
|
||||
}
|
||||
|
||||
/** Whether this event occupies more than one calendar day in [zone]. */
|
||||
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
||||
spanFirstDay(zone) != spanLastDay(zone)
|
||||
|
||||
data class EventDetail(
|
||||
val instance: EventInstance,
|
||||
val description: String?,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.plus
|
||||
|
||||
/**
|
||||
* The first [limit] dates a [SimpleRecurrence] fires on, starting at [start]
|
||||
* (DTSTART), for previewing a rule as dates instead of as words. A preview only,
|
||||
* kept to the shapes the picker can build; the provider stays the authority.
|
||||
*
|
||||
* Mirrors RFC 5545: [start] is always the first occurrence (§3.8.5.3), even when
|
||||
* the rule's own picks miss it; a monthly or yearly rule *skips* a period the
|
||||
* start day doesn't exist in rather than clamping; a weekly rule repeats in
|
||||
* blocks of `interval` weeks beginning on Monday (the default WKST, since
|
||||
* [toRRule] never writes one); [RecurrenceEnd.Count] counts real occurrences and
|
||||
* [RecurrenceEnd.Until] is inclusive.
|
||||
*
|
||||
* Returns fewer than [limit] dates when the series ends first, and an empty list
|
||||
* only when the rule yields nothing at all (an UNTIL before [start]).
|
||||
*/
|
||||
fun SimpleRecurrence.upcomingOccurrences(start: LocalDate, limit: Int): List<LocalDate> {
|
||||
if (limit <= 0) return emptyList()
|
||||
val until = (end as? RecurrenceEnd.Until)?.date
|
||||
val maxCount = (end as? RecurrenceEnd.Count)?.times ?: Int.MAX_VALUE
|
||||
val wanted = minOf(limit, maxCount)
|
||||
if (wanted <= 0) return emptyList()
|
||||
if (until != null && start > until) return emptyList()
|
||||
|
||||
// DTSTART is in the recurrence set whatever the rule picks, so seed with it
|
||||
// and let the walk skip anything landing on or before it.
|
||||
val result = mutableListOf(start)
|
||||
var period = 0
|
||||
// Periods can yield nothing (a skipped 31st), so the cap counts periods
|
||||
// examined rather than dates found.
|
||||
while (result.size < wanted && period < MAX_PERIODS) {
|
||||
for (date in occurrencesInPeriod(period, start)) {
|
||||
if (date <= start) continue
|
||||
if (until != null && date > until) return result
|
||||
result += date
|
||||
if (result.size == wanted) return result
|
||||
}
|
||||
period++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** The dates this rule's [period]-th repetition yields (empty when skipped). */
|
||||
private fun SimpleRecurrence.occurrencesInPeriod(period: Int, start: LocalDate): List<LocalDate> =
|
||||
when (freq) {
|
||||
RecurrenceFreq.Daily -> listOf(start.plus(period * interval, DateTimeUnit.DAY))
|
||||
RecurrenceFreq.Weekly -> weeklyOccurrences(period, start)
|
||||
RecurrenceFreq.Monthly -> {
|
||||
val month = start.plus(period * interval, DateTimeUnit.MONTH)
|
||||
// plus() clamps into the shorter month, but the rule skips such a
|
||||
// period — so a clamped date means "not this month".
|
||||
listOfNotNull(dateOrNull(month.year, month.month.number, start.day))
|
||||
}
|
||||
RecurrenceFreq.Yearly ->
|
||||
listOfNotNull(dateOrNull(start.year + period * interval, start.month.number, start.day))
|
||||
}
|
||||
|
||||
/**
|
||||
* One weekly block: every picked weekday inside the week that begins
|
||||
* `period * interval` weeks after the start's own week, in weekday order. With
|
||||
* no picks the rule simply repeats the start's weekday.
|
||||
*/
|
||||
private fun SimpleRecurrence.weeklyOccurrences(period: Int, start: LocalDate): List<LocalDate> {
|
||||
if (byDays.isEmpty()) return listOf(start.plus(period * interval, DateTimeUnit.WEEK))
|
||||
val daysIntoWeek = (start.dayOfWeek.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) %
|
||||
DAYS_PER_WEEK
|
||||
val weekStart = start
|
||||
.minus(daysIntoWeek, DateTimeUnit.DAY)
|
||||
.plus(period * interval, DateTimeUnit.WEEK)
|
||||
return byDays.sortedBy { it.isoDayNumber }.map { day ->
|
||||
weekStart.plus(
|
||||
(day.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) % DAYS_PER_WEEK,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a run of [occurrences] starting at [start] leaves its starting year,
|
||||
* i.e. whether showing them without a year would be ambiguous — a yearly rule
|
||||
* would otherwise read as the same date repeated.
|
||||
*/
|
||||
fun occurrencesSpanYears(occurrences: List<LocalDate>, start: LocalDate): Boolean =
|
||||
occurrences.any { it.year != start.year } || occurrences.map { it.year }.distinct().size > 1
|
||||
|
||||
/** [LocalDate] for a day-of-month that may not exist in that month; null if it doesn't. */
|
||||
private fun dateOrNull(year: Int, month: Int, day: Int): LocalDate? =
|
||||
runCatching { LocalDate(year, month, day) }.getOrNull()
|
||||
|
||||
private const val DAYS_PER_WEEK = 7
|
||||
|
||||
/**
|
||||
* How many repetitions to examine before giving up: generous enough for the
|
||||
* sparsest rule the picker can build, bounded so a rule whose occurrences all
|
||||
* fall outside its own UNTIL can't spin.
|
||||
*/
|
||||
private const val MAX_PERIODS = 2_000
|
||||
@@ -0,0 +1,225 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import java.text.Normalizer
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* One selectable zone, resolved for display at a given instant. [id] is the IANA
|
||||
* id we store in `EVENT_TIMEZONE` and show as the primary label (via [label]);
|
||||
* [shortName] its abbreviation ("CET", "WET", "UTC", or a "GMT+05:30" fallback);
|
||||
* [offsetMinutes] its offset. Both the abbreviation and the offset shift with
|
||||
* DST, so they're only meaningful next to the moment they were resolved for.
|
||||
*
|
||||
* [displayName] is the long localized name ("Central European Time"). It's kept
|
||||
* for search only — matching on it lets someone type "pacific" — and is *not*
|
||||
* displayed: spelled out next to the id it made the field too wide to fit.
|
||||
*/
|
||||
data class TimeZoneOption(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val shortName: String,
|
||||
val offsetMinutes: Int,
|
||||
) {
|
||||
/** The id as the primary label, underscores undone ("America/New York"). */
|
||||
val label: String get() = id.replace('_', ' ')
|
||||
|
||||
/** The trailing segment of the id ("Europe/Berlin" -> "Berlin"), underscores undone. */
|
||||
val city: String get() = id.substringAfterLast('/').replace('_', ' ')
|
||||
|
||||
/** The leading segment ("Europe/Berlin" -> "Europe"); empty for bare ids like "UTC". */
|
||||
val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
|
||||
/**
|
||||
* The four fields [filterTimeZones] matches on, normalized once here rather
|
||||
* than per query. Normalizing is not cheap — NFD decomposition plus a combining-
|
||||
* mark strip — and a search re-examines every option on every keystroke, so
|
||||
* doing it at construction turns ~2400 normalizations per character into a few
|
||||
* hundred plain `startsWith`/`contains` calls.
|
||||
*
|
||||
* Declared in the class body, so it stays out of `equals`/`hashCode`/`copy`:
|
||||
* it is derived state, and two options with the same id are the same option.
|
||||
*/
|
||||
internal val searchKeys: TimeZoneSearchKeys = TimeZoneSearchKeys(
|
||||
city = city.normalizeForSearch(),
|
||||
id = id.normalizeForSearch(),
|
||||
displayName = displayName.normalizeForSearch(),
|
||||
shortName = shortName.normalizeForSearch(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Pre-normalized match targets for one [TimeZoneOption]. */
|
||||
internal data class TimeZoneSearchKeys(
|
||||
val city: String,
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val shortName: String,
|
||||
)
|
||||
|
||||
private fun optionFor(
|
||||
zone: ZoneId,
|
||||
locale: Locale,
|
||||
at: Instant,
|
||||
regionOf: (String) -> String?,
|
||||
): TimeZoneOption = TimeZoneOption(
|
||||
id = zone.id,
|
||||
displayName = zone.getDisplayName(TextStyle.FULL, locale),
|
||||
shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf),
|
||||
offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60,
|
||||
)
|
||||
|
||||
/**
|
||||
* The zone abbreviation ("EDT", "CEST"), DST-correct at the resolved instant, or
|
||||
* a "GMT+05:30" form when no name exists.
|
||||
*
|
||||
* Two things make this fiddlier than it looks. First, it goes through
|
||||
* java.util.TimeZone, NOT java.time's "zzz" formatter: on Android the latter has
|
||||
* no short specific-zone names and degrades every zone to a "GMT-4" form (it
|
||||
* agrees with java.util only on desktop, which is why desktop can't catch it).
|
||||
* Second, ICU only surfaces the short name *commonly used in the display
|
||||
* locale's region* — a German-region English phone (en-DE) is shown "CEST" but
|
||||
* not "EDT", and an en-US phone the reverse. So ask in the display *language*
|
||||
* but the *zone's* region ("America/New_York" in en-US, "Europe/Berlin" in
|
||||
* en-DE) via [regionOf]. That covers almost everything; where a region still
|
||||
* yields no name (Athens in en-GR) the plain device locale sometimes does, so
|
||||
* try that next; failing both, the caller shows the offset.
|
||||
*
|
||||
* [regionOf] maps an IANA id to an ISO 3166 region and is injected because it
|
||||
* needs `android.icu`, which this pure-JVM module can't import. The default
|
||||
* (no region) leaves only the device-locale path — which is all a JVM test has
|
||||
* anyway, and enough there since desktop ICU isn't region-gated the same way.
|
||||
*/
|
||||
private fun resolveAbbreviation(
|
||||
id: String,
|
||||
dst: Boolean,
|
||||
locale: Locale,
|
||||
regionOf: (String) -> String?,
|
||||
): String {
|
||||
fun shortIn(loc: Locale): String =
|
||||
java.util.TimeZone.getTimeZone(id).getDisplayName(dst, java.util.TimeZone.SHORT, loc)
|
||||
val regional = regionOf(id)
|
||||
?.takeIf { it.length == 2 }
|
||||
?.let { shortIn(Locale(locale.language, it)) }
|
||||
if (regional != null && !regional.looksLikeOffset()) return regional
|
||||
// Either no region, or the region has no name for this zone: the device
|
||||
// locale is the next-best shot, and the offset the last resort.
|
||||
return shortIn(locale)
|
||||
}
|
||||
|
||||
/** True for the offset-style names ICU returns when a zone has no abbreviation. */
|
||||
private fun String.looksLikeOffset(): Boolean = this == "UTC" || startsWith("GMT")
|
||||
|
||||
/**
|
||||
* Every zone the JVM knows, resolved at [at]. [regionOf] (see
|
||||
* [resolveAbbreviation]) supplies the zone's region for the abbreviation.
|
||||
*
|
||||
* This is ~600 entries, each costing a localized display name plus up to two ICU
|
||||
* short-name lookups, so it is **not** cheap enough for the main thread — build
|
||||
* it off-thread once and filter the result with [filterTimeZones] rather than
|
||||
* rebuilding per keystroke.
|
||||
*
|
||||
* Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are
|
||||
* dropped: they're aliases the tz database keeps for compatibility, they'd
|
||||
* double up the real zones in the list, and none of them is what a user means
|
||||
* when they pick a place.
|
||||
*/
|
||||
fun timeZoneOptions(
|
||||
locale: Locale = Locale.getDefault(),
|
||||
at: Instant = Instant.now(),
|
||||
regionOf: (String) -> String? = { null },
|
||||
): List<TimeZoneOption> = ZoneId.getAvailableZoneIds()
|
||||
.asSequence()
|
||||
.filter { it.contains('/') && !it.startsWith("SystemV/") }
|
||||
.map { optionFor(ZoneId.of(it), locale, at, regionOf) }
|
||||
.sortedWith(compareBy({ it.region }, { it.city }))
|
||||
.toList()
|
||||
|
||||
/**
|
||||
* Resolve a single [id] the same way [timeZoneOptions] would, or null if the tz
|
||||
* database doesn't know it — for labelling one known zone without paying to
|
||||
* build the whole catalogue.
|
||||
*/
|
||||
fun timeZoneOptionOf(
|
||||
id: String,
|
||||
locale: Locale = Locale.getDefault(),
|
||||
at: Instant = Instant.now(),
|
||||
regionOf: (String) -> String? = { null },
|
||||
): TimeZoneOption? {
|
||||
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
|
||||
return optionFor(zone, locale, at, regionOf)
|
||||
}
|
||||
|
||||
/**
|
||||
* The short descriptor shown beneath the id, e.g. "CET · GMT+01:00". "UTC" reads
|
||||
* fine alone; any other offset-shaped name is normalised to our own GMT form so
|
||||
* the offset isn't stated twice in ICU's spelling and ours.
|
||||
*/
|
||||
fun zoneDescriptor(option: TimeZoneOption): String {
|
||||
val abbrev = option.shortName
|
||||
return when {
|
||||
abbrev == "UTC" -> "UTC"
|
||||
abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes)
|
||||
else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [options] matching [query], best matches first; a blank query returns the list
|
||||
* unchanged. Matching is accent- and case-insensitive and treats underscores as
|
||||
* spaces, so "sao paulo" finds "America/Sao_Paulo".
|
||||
*
|
||||
* Ranking puts a city that *starts with* the query above one that merely
|
||||
* contains it — typing "col" should reach Colombo before Turks_and_Caicos —
|
||||
* and the id is matched ahead of the localized name so a user who knows the
|
||||
* IANA id gets it first. An exact abbreviation hit ("CEST") ranks just under a
|
||||
* city prefix, so typing an abbreviation surfaces every zone that shows it
|
||||
* (all the CEST zones together); the abbreviation is the one resolved for the
|
||||
* display region, i.e. what the row actually shows.
|
||||
*/
|
||||
fun filterTimeZones(options: List<TimeZoneOption>, query: String): List<TimeZoneOption> {
|
||||
val needle = query.normalizeForSearch()
|
||||
if (needle.isEmpty()) return options
|
||||
return options
|
||||
.mapNotNull { option ->
|
||||
val keys = option.searchKeys
|
||||
val rank = when {
|
||||
keys.city.startsWith(needle) -> 0
|
||||
keys.shortName == needle -> 1
|
||||
keys.displayName.startsWith(needle) -> 2
|
||||
keys.shortName.startsWith(needle) -> 3
|
||||
keys.city.contains(needle) -> 4
|
||||
keys.id.contains(needle) -> 5
|
||||
keys.displayName.contains(needle) -> 6
|
||||
else -> return@mapNotNull null
|
||||
}
|
||||
rank to option
|
||||
}
|
||||
.sortedWith(compareBy({ it.first }, { it.second.city }))
|
||||
.map { it.second }
|
||||
}
|
||||
|
||||
/** Unicode combining marks — what NFD decomposition leaves an accent as. */
|
||||
private val COMBINING_MARKS = Regex("\\p{Mn}+")
|
||||
|
||||
/**
|
||||
* Lowercased, accent-stripped, underscores and slashes flattened to spaces, so
|
||||
* a query types the way a place is spoken rather than the way the tz database
|
||||
* spells it.
|
||||
*/
|
||||
private fun String.normalizeForSearch(): String =
|
||||
Normalizer.normalize(this, Normalizer.Form.NFD)
|
||||
.replace(COMBINING_MARKS, "")
|
||||
.replace('_', ' ')
|
||||
.replace('/', ' ')
|
||||
.lowercase(Locale.ROOT)
|
||||
.trim()
|
||||
|
||||
/** "GMT+02:00" / "GMT-05:30" / "GMT" — the offset as shown next to a zone. */
|
||||
fun formatGmtOffset(offsetMinutes: Int): String {
|
||||
if (offsetMinutes == 0) return "GMT"
|
||||
val sign = if (offsetMinutes < 0) '-' else '+'
|
||||
val abs = kotlin.math.abs(offsetMinutes)
|
||||
return "GMT%c%02d:%02d".format(sign, abs / 60, abs % 60)
|
||||
}
|
||||
@@ -0,0 +1,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,122 @@
|
||||
package de.jeanlucmakiola.calendula.domain.contacts
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* The kinds of contact "special date" Calendula mirrors into local calendars.
|
||||
* Each kind gets its own local calendar, so it inherits per-calendar colour,
|
||||
* visibility and reminder defaults for free. See
|
||||
* docs/design/contact-special-dates.md.
|
||||
*/
|
||||
enum class SpecialDateType {
|
||||
Birthday,
|
||||
Anniversary,
|
||||
|
||||
/** Everything else — a contact "Event" that is neither birthday nor anniversary. */
|
||||
Custom,
|
||||
}
|
||||
|
||||
/**
|
||||
* One dated event read from a device contact (a `ContactsContract` `Event`
|
||||
* row). The [lookupKey] is the stable contact identity used to reconcile the
|
||||
* mirror without duplicating; [year] is null when the contact stored the date
|
||||
* without one (`--MM-dd`), in which case age can't be shown.
|
||||
*/
|
||||
data class ContactSpecialDate(
|
||||
val lookupKey: String,
|
||||
val displayName: String,
|
||||
val type: SpecialDateType,
|
||||
val month: Int,
|
||||
val day: Int,
|
||||
val year: Int?,
|
||||
/** The contact's custom label for a [SpecialDateType.Custom] date, if any. */
|
||||
val label: String? = null,
|
||||
)
|
||||
|
||||
/** The parsed month/day (+ optional year) of a contact date. */
|
||||
data class ContactDateParts(val year: Int?, val month: Int, val day: Int)
|
||||
|
||||
/**
|
||||
* A fixed leap year to validate and anchor year-less dates against, so that a
|
||||
* `--02-29` birthday is representable (and, once anchored, only recurs in leap
|
||||
* years — matching how the calendar provider expands a yearly Feb-29 series).
|
||||
*/
|
||||
const val YEARLESS_ANCHOR_YEAR = 1972
|
||||
|
||||
/**
|
||||
* Parse a `ContactsContract.CommonDataKinds.Event.START_DATE` value into its
|
||||
* calendar parts, or null if it isn't a usable date. Handles the three shapes
|
||||
* seen in the wild:
|
||||
* - full `yyyy-MM-dd` (year known),
|
||||
* - year-less `--MM-dd` (year null),
|
||||
* - compact `yyyyMMdd`.
|
||||
*
|
||||
* A date that names an impossible day (e.g. `1999-02-29`) is rejected. Pure, so
|
||||
* it's unit-tested without a device.
|
||||
*/
|
||||
fun parseContactEventDate(raw: String?): ContactDateParts? {
|
||||
val s = raw?.trim().orEmpty()
|
||||
if (s.isEmpty()) return null
|
||||
|
||||
// Year-less: "--MM-dd" or "--MMdd".
|
||||
if (s.startsWith("--")) {
|
||||
val (m, d) = parseMonthDay(s.substring(2)) ?: return null
|
||||
return validated(null, m, d)
|
||||
}
|
||||
|
||||
if (s.contains('-')) {
|
||||
val parts = s.split('-').filter { it.isNotEmpty() }
|
||||
return when (parts.size) {
|
||||
// "yyyy-MM-dd" — but a leading '-' would have dropped the empty
|
||||
// first part, so require the first token to look like a year.
|
||||
3 -> if (s.startsWith('-')) null else validated(
|
||||
year = parts[0].toIntOrNull() ?: return null,
|
||||
month = parts[1].toIntOrNull() ?: return null,
|
||||
day = parts[2].toIntOrNull() ?: return null,
|
||||
)
|
||||
// Year-less "MM-dd" without the "--" prefix.
|
||||
2 -> validated(
|
||||
year = null,
|
||||
month = parts[0].toIntOrNull() ?: return null,
|
||||
day = parts[1].toIntOrNull() ?: return null,
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// Compact "yyyyMMdd".
|
||||
if (s.length == 8 && s.all { it.isDigit() }) {
|
||||
return validated(
|
||||
year = s.substring(0, 4).toInt(),
|
||||
month = s.substring(4, 6).toInt(),
|
||||
day = s.substring(6, 8).toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** "MM-dd" or compact "MMdd". */
|
||||
private fun parseMonthDay(rest: String): Pair<Int, Int>? {
|
||||
if (rest.contains('-')) {
|
||||
val p = rest.split('-').filter { it.isNotEmpty() }
|
||||
if (p.size != 2) return null
|
||||
return (p[0].toIntOrNull() ?: return null) to (p[1].toIntOrNull() ?: return null)
|
||||
}
|
||||
if (rest.length == 4 && rest.all { it.isDigit() }) {
|
||||
return rest.substring(0, 2).toInt() to rest.substring(2, 4).toInt()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm month/day form a real calendar date (validated against the actual
|
||||
* year when known, otherwise the leap anchor so `02-29` survives).
|
||||
*/
|
||||
private fun validated(year: Int?, month: Int, day: Int): ContactDateParts? {
|
||||
if (month !in 1..12 || day !in 1..31) return null
|
||||
val checkYear = year ?: YEARLESS_ANCHOR_YEAR
|
||||
return runCatching { LocalDate.of(checkYear, month, day) }
|
||||
.map { ContactDateParts(year, month, day) }
|
||||
.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.jeanlucmakiola.calendula.domain.contacts
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* Prefix of every managed-event `UID_2445`. Distinguishes mirror events from
|
||||
* user-created ones (which carry a random `<uuid>@calendula` UID), so the sync
|
||||
* only ever reconciles — and never deletes — events it actually owns.
|
||||
*/
|
||||
const val MANAGED_UID_PREFIX = "contact-"
|
||||
|
||||
/**
|
||||
* The deterministic `Events.UID_2445` that ties a mirrored event to its source
|
||||
* contact date. Stable across syncs (the reconciliation key), namespaced by
|
||||
* type so the same contact's birthday and anniversary never collide, and — when
|
||||
* a [discriminator] is given — by it too, so two Custom dates on one contact
|
||||
* (e.g. "Wedding" and "Graduation") get distinct events instead of clobbering
|
||||
* each other.
|
||||
*/
|
||||
fun managedEventUid(type: SpecialDateType, lookupKey: String, discriminator: String? = null): String {
|
||||
val disc = discriminator?.takeIf { it.isNotBlank() }?.let { ":$it" }.orEmpty()
|
||||
return "$MANAGED_UID_PREFIX${type.name.lowercase()}:$lookupKey$disc@calendula"
|
||||
}
|
||||
|
||||
/**
|
||||
* The reconciliation key for this date's mirrored event. Birthdays/anniversaries
|
||||
* are one-per-contact, so they key on the contact alone; a [SpecialDateType.Custom]
|
||||
* date adds a discriminator (its label, else its month-day) so distinct custom
|
||||
* dates on one contact don't collapse into a single event.
|
||||
*/
|
||||
fun ContactSpecialDate.managedUid(): String =
|
||||
managedEventUid(type, lookupKey, customDiscriminator())
|
||||
|
||||
private fun ContactSpecialDate.customDiscriminator(): String? =
|
||||
if (type == SpecialDateType.Custom) {
|
||||
label?.trim()?.lowercase()?.ifBlank { null } ?: "%02d-%02d".format(month, day)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* The all-day date the recurring `FREQ=YEARLY` series is anchored at: the real
|
||||
* date when the year is known (so age can be derived), otherwise the month/day
|
||||
* on a fixed leap anchor year so a `--02-29` date stays representable and the
|
||||
* anchor never drifts between syncs.
|
||||
*/
|
||||
fun ContactSpecialDate.anchorDate(): LocalDate =
|
||||
LocalDate(year ?: YEARLESS_ANCHOR_YEAR, month, day)
|
||||
|
||||
/**
|
||||
* Render a title [template] for a contact, substituting `{name}` and `{year}`
|
||||
* (the source year — a birthday's birth year or an anniversary's start year;
|
||||
* empty when [year] is null). Unlike an age, the year is static and correct on
|
||||
* every occurrence of the yearly event. Collapses the whitespace an empty
|
||||
* `{year}` may leave behind, so "{name}'s birthday ({year})" degrades cleanly to
|
||||
* "Jane's birthday" when no year is known.
|
||||
*/
|
||||
fun renderSpecialDateTitle(template: String, name: String, year: Int?): String =
|
||||
template
|
||||
.replace("{name}", name)
|
||||
.replace("{year}", year?.toString().orEmpty())
|
||||
// Drop an empty "()" left by an unresolved {year}, then tidy spacing.
|
||||
.replace(Regex("""\(\s*\)"""), "")
|
||||
.replace(Regex("""\s+"""), " ")
|
||||
.trim()
|
||||
@@ -0,0 +1,191 @@
|
||||
package de.jeanlucmakiola.calendula.domain.reminders
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
* Pure decision layer of in-house reminder delivery (#75): instances and reminder
|
||||
* offsets in, fire instants out. No provider, no clock. See docs/ARCHITECTURE.md.
|
||||
*/
|
||||
|
||||
/** An occurrence that reminders can hang off, flattened out of `Instances`. */
|
||||
data class ReminderEventInstance(
|
||||
val eventId: Long,
|
||||
val calendarId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
val title: String,
|
||||
val location: String?,
|
||||
val isAllDay: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* One occurrence paired with one of its reminder offsets, and the instant that
|
||||
* pairing has to fire at.
|
||||
*/
|
||||
data class PlannedReminder(
|
||||
val instance: ReminderEventInstance,
|
||||
val minutes: Int,
|
||||
val alarmMillis: Long,
|
||||
) {
|
||||
/**
|
||||
* Stable identity, keying the notification tag and the snooze/dismiss
|
||||
* `PendingIntent`s. Survives reboot, re-scan and reinstall.
|
||||
*/
|
||||
val key: Long = key(instance.eventId, instance.beginMillis, minutes)
|
||||
|
||||
private companion object {
|
||||
fun key(eventId: Long, beginMillis: Long, minutes: Int): Long {
|
||||
var h = eventId * 1_000_003L
|
||||
h = (h xor beginMillis) * 31L
|
||||
return h + minutes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What one scan concluded: post these now, and wake up again at [nextAlarmMillis]. */
|
||||
data class ReminderSchedule(
|
||||
val due: List<PlannedReminder>,
|
||||
val nextAlarmMillis: Long,
|
||||
)
|
||||
|
||||
private const val MILLIS_PER_MINUTE = 60_000L
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
|
||||
/**
|
||||
* Pair every instance with each of its event's reminder offsets.
|
||||
*
|
||||
* Timed occurrences fire at `begin − minutes`. All-day ones read the offset only
|
||||
* for *which day* it means ([allDayLeadDays]) and take the hour from
|
||||
* [allDayTimeMinutes], recomposed against each occurrence's own date in [zone].
|
||||
* Duplicate offsets in [minutesByEvent] collapse.
|
||||
*/
|
||||
fun planReminders(
|
||||
instances: List<ReminderEventInstance>,
|
||||
minutesByEvent: Map<Long, List<Int>>,
|
||||
zone: ZoneId,
|
||||
allDayTimeMinutes: Int,
|
||||
): List<PlannedReminder> = instances.flatMap { instance ->
|
||||
minutesByEvent[instance.eventId].orEmpty().distinct().map { minutes ->
|
||||
PlannedReminder(
|
||||
instance = instance,
|
||||
minutes = minutes,
|
||||
alarmMillis = if (instance.isAllDay) {
|
||||
allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes)
|
||||
} else {
|
||||
instance.beginMillis - minutes * MILLIS_PER_MINUTE
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** UTC midnight of an all-day occurrence, as the calendar date it stands for. */
|
||||
private fun allDayDate(beginMillis: Long): LocalDate =
|
||||
Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
|
||||
/**
|
||||
* How many whole days before its occurrence a raw all-day offset means.
|
||||
*
|
||||
* Our own rows fold the wanted hour into the offset, so the local date of the
|
||||
* encoded instant is the answer. A plain multiple of 1440 is a foreign bare lead
|
||||
* time and taken at face value instead — unless the instant lands on the hour the
|
||||
* setting names (within [NAMED_HOUR_TOLERANCE_MINUTES], for DST drift), where the
|
||||
* encodings collide and the tie goes to our own reading.
|
||||
*
|
||||
* Also used by
|
||||
* [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes] for
|
||||
* display, so screen and notification agree.
|
||||
*/
|
||||
internal fun allDayLeadDays(
|
||||
rawMinutes: Int,
|
||||
startDate: LocalDate,
|
||||
zone: ZoneId,
|
||||
allDayTimeMinutes: Int,
|
||||
): Long {
|
||||
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
val encoded = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE).atZone(zone)
|
||||
val minutesFromNamedHour = encoded.toLocalTime().let {
|
||||
val delta = (it.hour * 60 + it.minute - allDayTimeMinutes).mod(MINUTES_PER_DAY)
|
||||
minOf(delta, MINUTES_PER_DAY - delta)
|
||||
}
|
||||
if (rawMinutes % MINUTES_PER_DAY == 0 && minutesFromNamedHour > NAMED_HOUR_TOLERANCE_MINUTES) {
|
||||
return (rawMinutes / MINUTES_PER_DAY).toLong()
|
||||
}
|
||||
return ChronoUnit.DAYS.between(encoded.toLocalDate(), startDate)
|
||||
}
|
||||
|
||||
/** DST drift a row written in the other phase carries, rounded up past Lord Howe's half hour. */
|
||||
private const val NAMED_HOUR_TOLERANCE_MINUTES = 90
|
||||
|
||||
private fun allDayAlarmMillis(
|
||||
beginMillis: Long,
|
||||
rawMinutes: Int,
|
||||
zone: ZoneId,
|
||||
allDayTimeMinutes: Int,
|
||||
): Long = allDayDate(beginMillis)
|
||||
.minusDays(allDayLeadDays(rawMinutes, allDayDate(beginMillis), zone, allDayTimeMinutes))
|
||||
.atTime(LocalTime.of(allDayTimeMinutes / 60, allDayTimeMinutes % 60))
|
||||
.atZone(zone)
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
|
||||
/**
|
||||
* Split [planned] into what is due now and when to wake up next.
|
||||
*
|
||||
* Due means the fire instant falls in `(lastFiredMillis, nowMillis]`, so a scan
|
||||
* running twice cannot post twice while a late one still catches up. Reminders
|
||||
* whose event has ended are dropped ([isStillRelevant]). [nextAlarmMillis] is
|
||||
* capped at [horizonMillis] so the lookahead window keeps rolling forward.
|
||||
*/
|
||||
fun scheduleReminders(
|
||||
planned: List<PlannedReminder>,
|
||||
lastFiredMillis: Long,
|
||||
nowMillis: Long,
|
||||
horizonMillis: Long,
|
||||
): ReminderSchedule {
|
||||
val due = planned
|
||||
.filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis }
|
||||
.filter { it.instance.isStillRelevant(nowMillis) }
|
||||
.distinctBy { it.key }
|
||||
.sortedWith(compareBy({ it.instance.beginMillis }, { it.key }))
|
||||
val nextPending = planned
|
||||
.filter { it.alarmMillis > nowMillis }
|
||||
.minOfOrNull { it.alarmMillis }
|
||||
return ReminderSchedule(
|
||||
due = due,
|
||||
nextAlarmMillis = minOf(nextPending ?: horizonMillis, horizonMillis),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Still worth showing while the occurrence has not ended. Falls back to the
|
||||
* begin time when the end is unknown (0L).
|
||||
*/
|
||||
fun ReminderEventInstance.isStillRelevant(nowMillis: Long): Boolean =
|
||||
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
|
||||
|
||||
/**
|
||||
* The watermark a scan at [nowMillis] should measure against. A first-ever scan
|
||||
* claims the present rather than replaying everything since the epoch; a
|
||||
* watermark in the future (clock moved back) is clamped so it can't silence
|
||||
* every reminder until real time catches up.
|
||||
*/
|
||||
fun reminderWatermark(lastScanMillis: Long?, nowMillis: Long): Long =
|
||||
lastScanMillis?.coerceAtMost(nowMillis) ?: nowMillis
|
||||
|
||||
/**
|
||||
* How far ahead instances must be queried: the plain lookahead plus the longest
|
||||
* reminder offset, so a "two weeks before" is planned before it comes due. The
|
||||
* stretch is capped at [MAX_REMINDER_LEAD_MILLIS] — `maxReminderMinutes` is the
|
||||
* largest row in the whole provider, and a nonsense one would otherwise make
|
||||
* every scan expand every series over years.
|
||||
*/
|
||||
fun reminderQueryHorizon(lookaheadMillis: Long, maxReminderMinutes: Int): Long =
|
||||
lookaheadMillis + (maxReminderMinutes * MILLIS_PER_MINUTE).coerceIn(0L, MAX_REMINDER_LEAD_MILLIS)
|
||||
|
||||
/** Longest reminder offset a scan stretches its query window for — one year. */
|
||||
const val MAX_REMINDER_LEAD_MILLIS = 365L * 24 * 60 * 60 * 1000
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.jeanlucmakiola.calendula.qs
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.os.Build
|
||||
import android.service.quicksettings.TileService
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Quick Settings tile: tapping it opens the create-event form on today — the
|
||||
* same action as the launcher "New event" shortcut and the agenda widget's "+".
|
||||
* A stateless action tile, so there is no on/off state to keep in sync.
|
||||
*/
|
||||
class NewEventTileService : TileService() {
|
||||
|
||||
// The pre-34 branch intentionally uses the deprecated Intent overload: it is
|
||||
// the only form available below UpsideDownCake, and is reached only there.
|
||||
@Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
|
||||
override fun onClick() {
|
||||
super.onClick()
|
||||
val today = Clock.System.now()
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
val intent = MainActivity.openCreateIntent(this, today)
|
||||
// Launch only once the device is unlocked: creating an event behind the
|
||||
// keyguard makes no sense, and the shade can't start an activity over a
|
||||
// locked screen anyway.
|
||||
unlockAndRun {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
val pending = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
startActivityAndCollapse(pending)
|
||||
} else {
|
||||
startActivityAndCollapse(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -12,20 +14,30 @@ 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.Modifier
|
||||
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.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||
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
|
||||
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 kotlinx.datetime.LocalDate
|
||||
@@ -34,14 +46,24 @@ import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Holds the active top-level view (spec M1) and swaps between the calendar
|
||||
* 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].
|
||||
*
|
||||
* 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
|
||||
* (see [selectView]); a widget launch resets the stack to its own view; a date
|
||||
* tap drills the day view on top. Pressing back pops one level, and the
|
||||
* base-level [BackHandler] hands off to the system to exit once only the home
|
||||
* view remains. So back from a widget-opened screen returns to that widget's
|
||||
* view, then home; and back through pill switches walks the views in reverse.
|
||||
*
|
||||
* [requestedDetailKey] is an externally requested occurrence (a tapped
|
||||
* reminder notification routed through MainActivity): it opens the detail
|
||||
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed]
|
||||
* so a later recomposition can't re-open it.
|
||||
* so a later recomposition can't re-open it. (A widget event tap instead arrives
|
||||
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
|
||||
* widget's view.)
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarHost(
|
||||
@@ -52,15 +74,36 @@ fun CalendarHost(
|
||||
onWidgetNavConsumed: () -> Unit = {},
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: EventForm? = null,
|
||||
requestedInsertSource: ImportSource = ImportSource.Insert,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
requestedEditKey: LongArray? = null,
|
||||
onEditKeyConsumed: () -> Unit = {},
|
||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||
) {
|
||||
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
||||
// Wait for the persisted default view before seeding the stack, so the app
|
||||
// opens straight on the user's choice instead of flashing a placeholder and
|
||||
// correcting it. Brief blank first frame, matching the onboarding gate above.
|
||||
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
|
||||
|
||||
// View customisation (#24): the quick-switch cycle and drawer order. Both have
|
||||
// sensible non-empty initial values, so they're ready before the first frame.
|
||||
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
|
||||
val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value
|
||||
// Whether the jump-to-today control sits in each view's top bar or the FAB (#60).
|
||||
val todayInToolbar = viewModel.todayButtonInToolbar.collectAsStateWithLifecycle().value
|
||||
|
||||
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||
mutableStateOf(listOf(defaultView))
|
||||
}
|
||||
val view = viewStack.last()
|
||||
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
|
||||
|
||||
// Tapping a day in the month grid opens the day view anchored to that date.
|
||||
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val onOpenDay: (LocalDate) -> Unit = { date ->
|
||||
pendingDayIso = date.toString()
|
||||
view = CalendarView.Day
|
||||
viewStack = viewStack.drillToDay()
|
||||
}
|
||||
|
||||
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||
@@ -97,10 +140,20 @@ fun CalendarHost(
|
||||
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||
val onOpenSettings = { showSettings = true }
|
||||
|
||||
// Full-text search — its own overlay, opened from each calendar screen's
|
||||
// top bar. Sits below the detail/edit overlays so tapping a result reveals
|
||||
// the detail on top and backing out returns to the results.
|
||||
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||
val onOpenSearch = { showSearch = true }
|
||||
|
||||
// Calendar manager (reached from Settings) — its own overlay so it slides
|
||||
// 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)
|
||||
@@ -130,23 +183,105 @@ fun CalendarHost(
|
||||
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
||||
// Which channel filled [importForm]: an .ics file (prompt to apply the default
|
||||
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
|
||||
var importFormSource by remember { mutableStateOf(ImportSource.File) }
|
||||
// A restore (in-app "Restore from .ics" button) always runs the full import
|
||||
// flow — picker + summary — even for a single-event file, because the intent
|
||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||
// keeps routing a single event straight into the prefilled create form.
|
||||
var importForceMany by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(requestedImportUri) {
|
||||
if (requestedImportUri != null) {
|
||||
importUri = requestedImportUri
|
||||
importForceMany = false
|
||||
onImportConsumed()
|
||||
}
|
||||
}
|
||||
// An external ACTION_INSERT launch (another app/widget creating an event,
|
||||
// issue #30) arrives already prefilled — open it in the same create form the
|
||||
// single-event .ics path uses. [importForm] is the topmost overlay, so it
|
||||
// reveals on top of whatever was open without extra dismissal.
|
||||
LaunchedEffect(requestedInsertForm) {
|
||||
if (requestedInsertForm != null) {
|
||||
importFormSource = requestedInsertSource
|
||||
importForm = requestedInsertForm
|
||||
onInsertConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
// A home-screen widget launch asks to open a date (→ day view) or start a
|
||||
// create. Handled once and cleared, mirroring [requestedDetailKey].
|
||||
// Close every overlay that can sit over the calendar, so an externally
|
||||
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
||||
// top instead of underneath whatever the user had open.
|
||||
fun dismissCoveringOverlays() {
|
||||
showSettings = false
|
||||
showCalendars = false
|
||||
showBackup = false
|
||||
detailKey = null
|
||||
editKey = null
|
||||
importUri = null
|
||||
importForm = null
|
||||
}
|
||||
|
||||
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
|
||||
// widget) opens the occurrence straight in the edit form. Drop any covering
|
||||
// overlay first — the edit overlay sits below Settings/import in the Box, so
|
||||
// without this it would open hidden underneath them. Same held-key pattern as
|
||||
// a detail-screen "Edit" tap; a saved edit just returns to the calendar.
|
||||
LaunchedEffect(requestedEditKey) {
|
||||
if (requestedEditKey != null) {
|
||||
dismissCoveringOverlays()
|
||||
heldEditKey = requestedEditKey
|
||||
editKey = requestedEditKey
|
||||
onEditKeyConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
// A home-screen widget launch asks to open a date (→ day view), open an
|
||||
// event's detail, or start a create. Handled once and cleared, mirroring
|
||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||
// view so backing out returns there (then home), not to the default.
|
||||
LaunchedEffect(widgetNavRequest) {
|
||||
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
|
||||
view = CalendarView.Day
|
||||
// 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()
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
is WidgetNavRequest.OpenEvent -> {
|
||||
// Root the stack in the widget's view, then open the occurrence
|
||||
// detail over it (same key shape as a tapped event / reminder).
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
viewStack = viewBaseStack(defaultView, req.source)
|
||||
val key = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
|
||||
heldKey = key
|
||||
detailKey = key
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
is WidgetNavRequest.OpenView -> {
|
||||
// A widget header tap: land on a top-level view with no date
|
||||
// drill-in. Reveal it by dropping any covering overlay, then root
|
||||
// the stack on the target (null → the default home) over the
|
||||
// default home — so backing out returns to the default, then exits.
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
pendingDayIso = null
|
||||
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
is WidgetNavRequest.Create -> {
|
||||
// External "new event" entries (QS tile / launcher shortcut /
|
||||
// widget) must land on top of whatever is open — the form overlay
|
||||
// sits below Settings/calendars in the Box, so without this it
|
||||
// would open hidden underneath them.
|
||||
dismissCoveringOverlays()
|
||||
val iso = req.dateIso ?: Clock.System.now()
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
|
||||
heldCreateIso = iso
|
||||
@@ -161,36 +296,88 @@ fun CalendarHost(
|
||||
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
|
||||
// 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 || showBackup ||
|
||||
importUri != null || importForm != null
|
||||
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
||||
viewStack = viewStack.dropLast(1)
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
when (view) {
|
||||
// Switching between the peer views (month/week/day/agenda) is lateral
|
||||
// navigation, so it fades through rather than sliding — paging *within* a
|
||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||
val viewSwitch = fadeThrough()
|
||||
AnimatedContent(
|
||||
targetState = view,
|
||||
transitionSpec = { viewSwitch },
|
||||
label = "view-switch",
|
||||
) { currentView ->
|
||||
when (currentView) {
|
||||
CalendarView.Week -> WeekScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onCreateEvent = onCreateEvent,
|
||||
initialDateIso = pendingDayIso,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
selectedView = view,
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = view,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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(),
|
||||
) {
|
||||
SearchScreen(
|
||||
onBack = { showSearch = false },
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -211,6 +398,14 @@ fun CalendarHost(
|
||||
heldEditKey = key
|
||||
editKey = key
|
||||
},
|
||||
onDuplicate = { form ->
|
||||
// Reuse the prefilled-create overlay: a duplicate is a
|
||||
// fresh event, so it applies the default reminder like an
|
||||
// in-app new event (#52).
|
||||
importFormSource = ImportSource.Insert
|
||||
importForm = form
|
||||
detailKey = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -227,6 +422,7 @@ fun CalendarHost(
|
||||
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||
onClose = { createDateIso = null },
|
||||
onSaved = { createDateIso = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -246,6 +442,7 @@ fun CalendarHost(
|
||||
editKey = null
|
||||
detailKey = null
|
||||
},
|
||||
onManageCalendars = { showCalendars = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -259,26 +456,21 @@ fun CalendarHost(
|
||||
SettingsScreen(
|
||||
onBack = { showSettings = false },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
onOpenBackup = { showBackup = true },
|
||||
)
|
||||
}
|
||||
|
||||
// Calendar manager — slides over Settings.
|
||||
AnimatedVisibility(
|
||||
visible = showCalendars,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
CalendarsScreen(onBack = { showCalendars = false })
|
||||
}
|
||||
|
||||
// Import flow for an opened/received .ics file. A single event routes
|
||||
// into the create form (prefilled, for review); many open the picker.
|
||||
importUri?.let { uri ->
|
||||
ImportScreen(
|
||||
uri = uri,
|
||||
forceMany = importForceMany,
|
||||
onClose = { importUri = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
onOpenSingle = { form ->
|
||||
importUri = null
|
||||
importFormSource = ImportSource.File
|
||||
importForm = form
|
||||
},
|
||||
)
|
||||
@@ -287,9 +479,50 @@ fun CalendarHost(
|
||||
EventEditScreen(
|
||||
initialDateIso = null,
|
||||
initialForm = form,
|
||||
initialFormSource = importFormSource,
|
||||
onClose = { importForm = null },
|
||||
onSaved = { importForm = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
)
|
||||
}
|
||||
|
||||
// Declared last so it covers every overlay that can open it: Settings,
|
||||
// both event forms, and the .ics import picker (#76).
|
||||
AnimatedVisibility(
|
||||
visible = showCalendars,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
CalendarsScreen(
|
||||
onBack = { showCalendars = false },
|
||||
onOpenBackup = { showBackup = true },
|
||||
)
|
||||
}
|
||||
|
||||
// Backup & restore — over the manager, since the manager links into it.
|
||||
AnimatedVisibility(
|
||||
visible = showBackup,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
BackupScreen(
|
||||
onBack = { showBackup = false },
|
||||
// Restore runs the normal .ics import, and both this screen and
|
||||
// the manager that can have opened it are declared above the
|
||||
// import overlays — so both have to step aside.
|
||||
onImport = {
|
||||
importUri = it
|
||||
importForceMany = true
|
||||
showBackup = false
|
||||
showCalendars = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Persists the view back stack across config change / process death by ordinal. */
|
||||
private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
||||
save = { stack -> stack.map(CalendarView::ordinal) },
|
||||
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Supplies [CalendarHost] with the user's default startup view (M1). Held as a
|
||||
* nullable [StateFlow] so the host can wait for DataStore's first emission before
|
||||
* seeding its view back stack — rendering nothing for that first frame rather
|
||||
* than flashing the old hard-coded Week view and then correcting it.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CalendarHostViewModel @Inject constructor(
|
||||
prefs: SettingsPrefs,
|
||||
) : ViewModel() {
|
||||
|
||||
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */
|
||||
val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig
|
||||
.map { it.cycle }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = IMPLEMENTED_VIEWS,
|
||||
)
|
||||
|
||||
/** Order of the views in the navigation drawer (#24); every view always shown. */
|
||||
val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = IMPLEMENTED_VIEWS,
|
||||
)
|
||||
|
||||
/** Whether each view's jump-to-today control lives in the top bar, not the FAB (#60). */
|
||||
val todayButtonInToolbar: StateFlow<Boolean> = prefs.todayButtonInToolbar
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,12 @@ 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.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -16,10 +20,15 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeDialog
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun RootScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -29,6 +38,12 @@ fun RootScreen(
|
||||
onWidgetNavConsumed: () -> Unit = {},
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
|
||||
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
requestedEditKey: LongArray? = null,
|
||||
onEditKeyConsumed: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
@@ -38,6 +53,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 ->
|
||||
@@ -45,19 +64,44 @@ fun RootScreen(
|
||||
hasPermission = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_CALENDAR
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
// Refresh the contact special-dates mirror on foreground (the
|
||||
// worker is debounced and no-ops when the feature is off). Gated
|
||||
// on the permission so users without the opt-in never enqueue it.
|
||||
if (context.hasContactsPermission()) {
|
||||
SpecialDatesScheduler.runNow(context, foreground = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(obs)
|
||||
onDispose { lifecycle.removeObserver(obs) }
|
||||
}
|
||||
|
||||
if (hasPermission) {
|
||||
// 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()
|
||||
when (onboardingDone) {
|
||||
// One-time explainer for the switch to the device's own calendar
|
||||
// visibility (#75), armed by the reconciler.
|
||||
val visibilityNotice: CalendarVisibilityNoticeViewModel = hiltViewModel()
|
||||
val noticePending by visibilityNotice.pending.collectAsStateWithLifecycle()
|
||||
// Runs on entry however the permission was granted, including via
|
||||
// Android's app-settings screen (caught by the ON_RESUME above).
|
||||
LaunchedEffect(Unit) {
|
||||
visibilityNotice.reconcile()
|
||||
if (!grantedAtLaunch) reminderOnboarding.rearmAfterGrant()
|
||||
}
|
||||
if (onboardingDone == true && noticePending) {
|
||||
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
|
||||
}
|
||||
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
||||
when (done) {
|
||||
true -> CalendarHost(
|
||||
modifier = modifier,
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
@@ -66,6 +110,11 @@ fun RootScreen(
|
||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = onImportConsumed,
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
requestedInsertSource = requestedInsertSource,
|
||||
onInsertConsumed = onInsertConsumed,
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = onEditKeyConsumed,
|
||||
)
|
||||
false -> ReminderOnboardingScreen(
|
||||
onFinished = reminderOnboarding::finish,
|
||||
@@ -73,6 +122,7 @@ fun RootScreen(
|
||||
)
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PermissionScreen(
|
||||
onGranted = { hasPermission = true },
|
||||
@@ -80,3 +130,4 @@ fun RootScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,43 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
|
||||
/**
|
||||
* A navigation a home-screen widget asked the app to perform when launched.
|
||||
* Parsed from the launch intent in MainActivity and consumed once by
|
||||
* [CalendarHost] (event taps reuse the existing reminder detail-key channel, so
|
||||
* they are not modelled here).
|
||||
* [CalendarHost]. Every request carries the [source] view of the widget it came
|
||||
* from (the agenda widget → [CalendarView.Agenda], the month widget →
|
||||
* [CalendarView.Month]) so the in-app back stack roots itself in that view:
|
||||
* backing out of the opened date/event returns to the widget's own view, not the
|
||||
* default home. (Reminder notifications are not widgets — they keep the separate
|
||||
* detail-key channel and leave the base view untouched.)
|
||||
*/
|
||||
sealed interface WidgetNavRequest {
|
||||
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date). */
|
||||
data class OpenDate(val dateIso: String) : WidgetNavRequest
|
||||
/**
|
||||
* Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over
|
||||
* [source]. A null [source] means the request came from outside the app (a
|
||||
* launcher/clock date tap, issue #9) rather than a widget, so it roots over
|
||||
* the default home view instead of a widget's view.
|
||||
*/
|
||||
data class OpenDate(val dateIso: String, val source: CalendarView?) : WidgetNavRequest
|
||||
|
||||
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
|
||||
data class OpenEvent(
|
||||
val eventId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
val source: CalendarView,
|
||||
) : WidgetNavRequest
|
||||
|
||||
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
||||
data class Create(val dateIso: String?) : WidgetNavRequest
|
||||
|
||||
/**
|
||||
* Open the app rooted on a top-level [view] with no date drill-in — a widget
|
||||
* header tap. A null [view] means "the user's default home view" (the agenda
|
||||
* widget's "Upcoming" title, issue #20); a concrete view roots there over the
|
||||
* default home (the month widget's month/year title → [CalendarView.Month],
|
||||
* issue #18), so backing out returns to the default view, then exits.
|
||||
*/
|
||||
data class OpenView(val view: CalendarView?) : WidgetNavRequest
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* How far ahead the agenda (screen or widget) shows events, starting at today.
|
||||
*
|
||||
* Two flavours:
|
||||
* - **Rolling** windows of a fixed length: [Day] (today), [Week] (7 days),
|
||||
* [Month] (30 days), [Custom] (1–365 days). These never degenerate.
|
||||
* - **Calendar-aligned** windows that end at a period boundary: [ThisWeek] runs
|
||||
* through the end of the current week (respecting the week-start preference —
|
||||
* a Monday start means "everything before next Monday"); [ThisMonth] runs
|
||||
* through the last day of the current month. These shrink as the period ends.
|
||||
*/
|
||||
sealed interface AgendaRange {
|
||||
data object Day : AgendaRange
|
||||
data object Week : AgendaRange
|
||||
data object Month : AgendaRange
|
||||
data object ThisWeek : AgendaRange
|
||||
data object ThisMonth : AgendaRange
|
||||
data class Custom(val days: Int) : AgendaRange
|
||||
|
||||
companion object {
|
||||
/** Allowed bounds for a [Custom] day count. */
|
||||
const val MIN_CUSTOM_DAYS = 1
|
||||
const val MAX_CUSTOM_DAYS = 365
|
||||
}
|
||||
}
|
||||
|
||||
private const val DAYS_PER_WEEK = 7
|
||||
|
||||
/**
|
||||
* Inclusive number of days the window spans, starting at (and including)
|
||||
* [anchor]. The calendar-aligned ranges depend on the anchor day and, for
|
||||
* [AgendaRange.ThisWeek], the [weekStart] preference.
|
||||
*/
|
||||
fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (this) {
|
||||
AgendaRange.Day -> 1
|
||||
AgendaRange.Week -> DAYS_PER_WEEK
|
||||
AgendaRange.Month -> 30
|
||||
AgendaRange.ThisWeek -> {
|
||||
// Days elapsed since the week's first day (0 when today *is* the start),
|
||||
// so the window is the rest of the week through the day before it repeats.
|
||||
val sinceWeekStart = ((anchor.dayOfWeek.ordinal - weekStart.ordinal) + DAYS_PER_WEEK) % DAYS_PER_WEEK
|
||||
DAYS_PER_WEEK - sinceWeekStart
|
||||
}
|
||||
AgendaRange.ThisMonth -> {
|
||||
val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth()
|
||||
daysInMonth - anchor.day + 1
|
||||
}
|
||||
is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)
|
||||
}
|
||||
|
||||
/** Stored representation: a fixed token, or `CUSTOM:<days>`. */
|
||||
fun AgendaRange.storageValue(): String = when (this) {
|
||||
AgendaRange.Day -> "DAY"
|
||||
AgendaRange.Week -> "WEEK"
|
||||
AgendaRange.Month -> "MONTH"
|
||||
AgendaRange.ThisWeek -> "THIS_WEEK"
|
||||
AgendaRange.ThisMonth -> "THIS_MONTH"
|
||||
is AgendaRange.Custom -> "$CUSTOM_PREFIX$days"
|
||||
}
|
||||
|
||||
/** Parse a stored value; unknown/garbage falls back to [default]. */
|
||||
fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when {
|
||||
stored == "DAY" -> AgendaRange.Day
|
||||
stored == "WEEK" -> AgendaRange.Week
|
||||
stored == "MONTH" -> AgendaRange.Month
|
||||
stored == "THIS_WEEK" -> AgendaRange.ThisWeek
|
||||
stored == "THIS_MONTH" -> AgendaRange.ThisMonth
|
||||
stored != null && stored.startsWith(CUSTOM_PREFIX) -> {
|
||||
val days = stored.removePrefix(CUSTOM_PREFIX).toIntOrNull()
|
||||
if (days != null) {
|
||||
AgendaRange.Custom(days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS))
|
||||
} else {
|
||||
default
|
||||
}
|
||||
}
|
||||
else -> default
|
||||
}
|
||||
|
||||
/**
|
||||
* The concrete span the range covers, starting at [start] through [end]
|
||||
* (inclusive), for a human-readable header. Both ends use the same day-month
|
||||
* order so a span never mixes "15 Jul" with "Aug 13, 2026":
|
||||
* - [AgendaRange.Day] → a single date ("27 Jun 2026")
|
||||
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026"), 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)
|
||||
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)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val CUSTOM_PREFIX = "CUSTOM:"
|
||||
@@ -0,0 +1,207 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Coffee
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.util.Locale
|
||||
|
||||
// The agenda's row vocabulary, split out of AgendaScreen so the month view's
|
||||
// split style can list a day with exactly the same visual language instead of
|
||||
// growing a parallel set of event rows.
|
||||
|
||||
@Composable
|
||||
internal fun AgendaDayHeader(
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpenDay(date) },
|
||||
) {
|
||||
Text(
|
||||
text = agendaDayLabel(date, today),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = if (date == today) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A card standing in for a day with no events — the same coffee-cup motif as the
|
||||
* agenda's full-screen empty state, boxed into a card so the day keeps a visible slot
|
||||
* rather than a bare header. Used for an anchored, event-less today (#35) and for
|
||||
* an empty selected day in the month view's split style.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AgendaEmptyDayRow(text: String, onClick: () -> Unit) {
|
||||
Card(
|
||||
// Match a single event row's resting corner radius (floret groupedShape).
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 18.dp, horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Coffee,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AgendaEventRow(
|
||||
event: EventInstance,
|
||||
day: LocalDate,
|
||||
zone: TimeZone,
|
||||
position: Position,
|
||||
dimmed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = title,
|
||||
summary = agendaTimeSummary(event, day, zone),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
leading = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 6.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(eventAccent(event.color, dark, soften)),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
||||
@Composable
|
||||
internal fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
||||
val relative = when (date) {
|
||||
today -> stringResource(R.string.agenda_header_today)
|
||||
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
||||
else -> null
|
||||
}
|
||||
val formatted = formatAgendaDate(date)
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
/**
|
||||
* Time line under the title: "09:00 – 10:00 · Location", "All day", etc.
|
||||
*
|
||||
* A multi-day event shows only the part relevant to [day], spelled out so each
|
||||
* day reads on its own: its first day names the start ("Starts 14:00"), its last
|
||||
* day the end ("Ends 10:00"), and any whole day in between reads as "All day".
|
||||
* An all-day multi-day event is simply "All day" on every day it covers.
|
||||
*/
|
||||
@Composable
|
||||
internal fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String {
|
||||
val is24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
|
||||
val time = when (val label = agendaTimeLabel(event, day, zone)) {
|
||||
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
|
||||
is AgendaTimeLabel.Starts -> stringResource(
|
||||
R.string.agenda_span_starts,
|
||||
formatTime(label.start, zone, is24Hour, locale),
|
||||
)
|
||||
is AgendaTimeLabel.Ends -> stringResource(
|
||||
R.string.agenda_span_ends,
|
||||
formatTime(label.end, zone, is24Hour, locale),
|
||||
)
|
||||
is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " +
|
||||
formatTime(label.end, zone, is24Hour, locale)
|
||||
}
|
||||
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
private fun formatTime(
|
||||
instant: Instant,
|
||||
zone: TimeZone,
|
||||
is24Hour: Boolean,
|
||||
locale: Locale,
|
||||
): String {
|
||||
val t = instant.toLocalDateTime(zone).time
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
||||
}
|
||||
|
||||
private fun formatAgendaDate(date: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
|
||||
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
|
||||
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -15,18 +14,19 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.EventAvailable
|
||||
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.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
@@ -34,10 +34,12 @@ import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
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.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -45,51 +47,64 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
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.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
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.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
// No file-level zone constant here on purpose: it would be fixed for the process
|
||||
// lifetime and drift from the zone AgendaViewModel groups in after a device
|
||||
// time-zone change. The zone travels on AgendaUiState.Success instead.
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AgendaScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
todayInToolbar: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AgendaViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
var showRangePicker by remember { mutableStateOf(false) }
|
||||
|
||||
val isOnToday = when (val s = state) {
|
||||
is AgendaUiState.Success -> s.anchor == s.today
|
||||
else -> true
|
||||
}
|
||||
val successState = state as? AgendaUiState.Success
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -97,6 +112,8 @@ fun AgendaScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = anchor,
|
||||
drawerState = drawerState,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
@@ -117,37 +134,155 @@ fun AgendaScreen(
|
||||
topBar = {
|
||||
AgendaTopBar(
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = viewModel::goToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnToday,
|
||||
todayVisible = !isOnToday && !todayInToolbar,
|
||||
todayText = stringResource(R.string.agenda_today_action),
|
||||
onToday = viewModel::goToToday,
|
||||
onCreate = { onCreateEvent(anchor, null) },
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
AgendaContent(
|
||||
state = state,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
// One bar at the top: the "showing …" header on the left and the
|
||||
// session range switcher on the right (one settings toggle).
|
||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
// end aligns the selector's right edge with the top-bar view
|
||||
// switcher (its 8.dp margin + the app bar's 4.dp inset).
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
AgendaRangeBanner(
|
||||
range = s.range,
|
||||
start = s.anchor,
|
||||
end = s.rangeEnd,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
AgendaRangePill(
|
||||
range = s.range,
|
||||
isOverride = s.rangeIsOverride,
|
||||
onClick = { showRangePicker = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
AgendaContent(
|
||||
state = state,
|
||||
pastDisplay = pastDisplay,
|
||||
showToday = showToday,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showRangePicker) {
|
||||
AgendaRangePicker(
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The agenda's current range, tapped to open the range picker as a session-only
|
||||
* override. Shares the top-bar view switcher's button shape so the two read as a
|
||||
* family, but stays low-emphasis — a subtle neutral surface tint rather than the
|
||||
* switcher's secondary container, so it doesn't compete. Fills with the primary
|
||||
* container only while an override is active, to make that temporary state clear.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangePill(
|
||||
range: AgendaRange,
|
||||
isOverride: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = if (isOverride) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
},
|
||||
contentColor = if (isOverride) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = agendaRangeLabel(range),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A header naming the concrete window currently shown under a "showing …" label,
|
||||
* e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name
|
||||
* lives on the selector button beside it, so it isn't repeated here.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangeBanner(
|
||||
range: AgendaRange,
|
||||
start: LocalDate,
|
||||
end: LocalDate,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
val window = agendaRangeWindowSummary(range, start, end, locale)
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.agenda_range_showing_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Just the concrete dates — the range's name ("Next 30 days") already
|
||||
// sits on the selector button to the right, so repeating it here is noise.
|
||||
Text(
|
||||
text = window,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaContent(
|
||||
state: AgendaUiState,
|
||||
pastDisplay: PastEventDisplay,
|
||||
showToday: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (state) {
|
||||
@@ -155,11 +290,41 @@ private fun AgendaContent(
|
||||
is AgendaUiState.Failure -> Box(modifier) {
|
||||
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||
}
|
||||
is AgendaUiState.Success ->
|
||||
if (state.days.isEmpty()) {
|
||||
is AgendaUiState.Success -> {
|
||||
val now by rememberCurrentMinute()
|
||||
// Hiding drops finished events — and any day they leave empty; dimming
|
||||
// keeps them but fades the row. Recomputed each minute so events fall
|
||||
// away (or fade) as they end while the screen stays open.
|
||||
val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
state.days.mapNotNull { day ->
|
||||
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||
}
|
||||
} else {
|
||||
state.days
|
||||
}
|
||||
// Anchor today with a "nothing left today" placeholder — but only when
|
||||
// the window actually starts on today; a jumped-to date has no today in
|
||||
// it, so anchoring there would be misleading (#35).
|
||||
val days = anchorTodayIfMissing(
|
||||
days = filtered,
|
||||
today = state.today,
|
||||
enabled = showToday && state.anchor == state.today,
|
||||
)
|
||||
if (days.isEmpty()) {
|
||||
AgendaEmpty(modifier)
|
||||
} else {
|
||||
AgendaList(state = state, onEventClick = onEventClick, modifier = modifier)
|
||||
AgendaList(
|
||||
days = days,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||
now = now,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,8 +332,13 @@ private fun AgendaContent(
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun AgendaList(
|
||||
state: AgendaUiState.Success,
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
dimPast: Boolean,
|
||||
now: Instant,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
@@ -176,69 +346,42 @@ private fun AgendaList(
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
state.days.forEach { day ->
|
||||
days.forEach { day ->
|
||||
stickyHeader(key = "header-${day.date}") {
|
||||
AgendaDayHeader(date = day.date, today = state.today)
|
||||
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
||||
}
|
||||
if (day.events.isEmpty()) {
|
||||
// An anchored, event-less today (#35) — "nothing left today".
|
||||
item(key = "placeholder-${day.date}") {
|
||||
AgendaEmptyDayRow(
|
||||
text = stringResource(R.string.agenda_no_more_today),
|
||||
onClick = { onOpenDay(day.date) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(
|
||||
items = day.events,
|
||||
key = { _, event -> event.instanceId },
|
||||
// Scope the key by day: a multi-day event appears under every
|
||||
// day it spans, so its instanceId alone is not unique across
|
||||
// the list (LazyColumn requires unique keys).
|
||||
key = { _, event -> "${day.date}-${event.instanceId}" },
|
||||
) { index, event ->
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
day = day.date,
|
||||
zone = zone,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = animateItemMotion(),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = agendaDayLabel(date, today),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = if (date == today) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaEventRow(
|
||||
event: EventInstance,
|
||||
position: Position,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
title = title,
|
||||
summary = agendaTimeSummary(event),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
leading = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 6.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(pastelize(event.color, dark)),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
@@ -247,7 +390,7 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.EventAvailable,
|
||||
imageVector = Icons.Filled.Coffee,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(48.dp),
|
||||
@@ -258,13 +401,6 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.agenda_empty_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +410,9 @@ private fun AgendaTopBar(
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
@@ -292,6 +431,13 @@ private fun AgendaTopBar(
|
||||
}
|
||||
},
|
||||
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,
|
||||
@@ -305,40 +451,3 @@ private fun AgendaTopBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
||||
@Composable
|
||||
private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
||||
val relative = when (date) {
|
||||
today -> stringResource(R.string.agenda_header_today)
|
||||
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
||||
else -> null
|
||||
}
|
||||
val formatted = formatAgendaDate(date)
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
/** Time line under the title: "09:00 – 10:00 · Location", "All day", etc. */
|
||||
@Composable
|
||||
private fun agendaTimeSummary(event: EventInstance): String {
|
||||
val time = if (event.isAllDay) {
|
||||
stringResource(R.string.event_detail_all_day)
|
||||
} else {
|
||||
"${formatTime(event.start)} – ${formatTime(event.end)}"
|
||||
}
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
private fun formatTime(instant: Instant): String {
|
||||
val t = instant.toLocalDateTime(zone).time
|
||||
return "%02d:%02d".format(t.hour, t.minute)
|
||||
}
|
||||
|
||||
private fun formatAgendaDate(date: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
return "$weekday, ${date.day}. $monthName ${date.year}"
|
||||
}
|
||||
|
||||
@@ -2,9 +2,43 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* What an agenda row's time line should convey for an event on a given day —
|
||||
* the part of a multi-day span that [day] falls in. Pure and shared so the
|
||||
* agenda screen and the agenda widget label multi-day events identically; each
|
||||
* surface only formats the instants into its own locale/24h string.
|
||||
*/
|
||||
sealed interface AgendaTimeLabel {
|
||||
/** An all-day event, or a whole in-between day of a multi-day span. */
|
||||
data object AllDay : AgendaTimeLabel
|
||||
/** The first day of a multi-day timed event: when it begins. */
|
||||
data class Starts(val start: Instant) : AgendaTimeLabel
|
||||
/** The last day of a multi-day timed event: when it ends. */
|
||||
data class Ends(val end: Instant) : AgendaTimeLabel
|
||||
/** A single-day timed event: its start–end range. */
|
||||
data class Range(val start: Instant, val end: Instant) : AgendaTimeLabel
|
||||
}
|
||||
|
||||
/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */
|
||||
fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel {
|
||||
if (event.isAllDay) return AgendaTimeLabel.AllDay
|
||||
val firstDay = event.spanFirstDay(zone)
|
||||
val lastDay = event.spanLastDay(zone)
|
||||
return when {
|
||||
firstDay == lastDay -> AgendaTimeLabel.Range(event.start, event.end)
|
||||
day <= firstDay -> AgendaTimeLabel.Starts(event.start)
|
||||
day >= lastDay -> AgendaTimeLabel.Ends(event.end)
|
||||
else -> AgendaTimeLabel.AllDay // a full in-between day
|
||||
}
|
||||
}
|
||||
|
||||
/** One calendar day with at least one event, for the agenda list. */
|
||||
data class AgendaDay(
|
||||
@@ -15,22 +49,41 @@ data class AgendaDay(
|
||||
|
||||
/**
|
||||
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
|
||||
* actually carry events). An event that began before [anchor] (ongoing or
|
||||
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a
|
||||
* day, all-day events sort first, then ascending by start time, then title.
|
||||
* actually carry events). A multi-day event surfaces on *every* day it spans,
|
||||
* not just its first — clamped to [[anchor], [windowEnd]] so an event that began
|
||||
* before the window (ongoing) still lists from the anchor day, and one running
|
||||
* past the window stops at the last visible day. Within a day, all-day events
|
||||
* sort first, then ascending by start time, then title.
|
||||
*
|
||||
* Shared by the Agenda screen and the agenda home-screen widget so both group
|
||||
* and order identically.
|
||||
*/
|
||||
fun groupAgendaDays(
|
||||
anchor: LocalDate,
|
||||
windowEnd: LocalDate,
|
||||
instances: List<EventInstance>,
|
||||
zone: TimeZone,
|
||||
): List<AgendaDay> =
|
||||
instances
|
||||
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) }
|
||||
.toSortedMap()
|
||||
.map { (date, dayEvents) ->
|
||||
): List<AgendaDay> {
|
||||
val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>()
|
||||
for (instance in instances) {
|
||||
val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor)
|
||||
val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd)
|
||||
// Skip instances that don't actually occupy any day in [[anchor], [windowEnd]].
|
||||
// The provider returns an event whenever its instant span overlaps the query
|
||||
// window, but all-day events live at UTC midnights with an exclusive end: east
|
||||
// of UTC that end dips just past local midnight, so *yesterday's* all-day event
|
||||
// overlaps today's window start and comes back even though its true last day
|
||||
// (resolved in UTC) is before the anchor. Clamping it up to the anchor would
|
||||
// surface it under "today" (issue #65); drop it instead. The symmetric case —
|
||||
// a next-day all-day event overlapping the window's last instant — drops too.
|
||||
if (lastDay < firstDay) continue
|
||||
var day = firstDay
|
||||
while (day <= lastDay) {
|
||||
byDay.getOrPut(day) { mutableListOf() }.add(instance)
|
||||
day = day.plus(1, DateTimeUnit.DAY)
|
||||
}
|
||||
}
|
||||
return byDay.map { (date, dayEvents) ->
|
||||
AgendaDay(
|
||||
date = date,
|
||||
events = dayEvents.sortedWith(
|
||||
@@ -40,6 +93,25 @@ fun groupAgendaDays(
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure [today] surfaces as the first agenda day even when it carries no
|
||||
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget
|
||||
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and
|
||||
* when today already has its own day in [days]. Keeps today anchored at the top
|
||||
* so a glance tells today's events apart from a future day's (issue #35).
|
||||
*/
|
||||
fun anchorTodayIfMissing(
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
enabled: Boolean,
|
||||
): List<AgendaDay> =
|
||||
if (enabled && days.none { it.date == today }) {
|
||||
listOf(AgendaDay(today, emptyList())) + days
|
||||
} else {
|
||||
days
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
||||
@@ -53,5 +125,20 @@ sealed interface AgendaUiState {
|
||||
val anchor: LocalDate,
|
||||
val today: LocalDate,
|
||||
val days: List<AgendaDay>,
|
||||
/** The range currently in effect — the saved default or a session override. */
|
||||
val range: AgendaRange,
|
||||
/** True when [range] is a temporary in-view override of the saved default. */
|
||||
val rangeIsOverride: Boolean,
|
||||
/** Last day the current [range] covers (inclusive), for the range header. */
|
||||
val rangeEnd: LocalDate,
|
||||
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
|
||||
val showRangeBar: Boolean,
|
||||
/**
|
||||
* The zone [days] were grouped in. Carried in the state rather than
|
||||
* re-read by the screen so labelling and grouping cannot disagree: an
|
||||
* event's "Starts …/Ends …/All day" line is only correct relative to the
|
||||
* same zone that decided which day it was filed under.
|
||||
*/
|
||||
val zone: TimeZone,
|
||||
) : AgendaUiState
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
@@ -19,6 +22,7 @@ import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
@@ -30,16 +34,52 @@ import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/** How far ahead the agenda loads events from its anchor day. */
|
||||
internal const val AGENDA_WINDOW_DAYS = 60
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class AgendaViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
// The saved agenda range plus the range-bar visibility toggle.
|
||||
private val agendaSettings = combine(
|
||||
settingsPrefs.agendaScreenRange,
|
||||
settingsPrefs.agendaShowRangeBar,
|
||||
) { range, showBar -> AgendaSettings(range, showBar) }
|
||||
|
||||
/**
|
||||
* 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
|
||||
* concern only, so it rides alongside the data state rather than re-querying;
|
||||
* the screen combines it with a per-minute "now" to fade or drop past rows.
|
||||
*/
|
||||
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = PastEventDisplay.SHOW,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether to keep today anchored at the top with a "nothing left today"
|
||||
* placeholder even once it has no remaining events (#35). A display concern
|
||||
* applied in the composition (after past-event filtering), so it rides
|
||||
* alongside the data state like [pastEventDisplay] rather than re-querying.
|
||||
*/
|
||||
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = true,
|
||||
)
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
|
||||
private val todayDate: LocalDate
|
||||
@@ -48,14 +88,32 @@ class AgendaViewModel @Inject constructor(
|
||||
private val _anchor = MutableStateFlow(todayDate)
|
||||
val anchor: StateFlow<LocalDate> = _anchor
|
||||
|
||||
val state: StateFlow<AgendaUiState> = _anchor
|
||||
.flatMapLatest { anchor ->
|
||||
val range = agendaRange(anchor, AGENDA_WINDOW_DAYS, zone)
|
||||
// A transient, in-view override of the saved agenda range. Held in memory
|
||||
// (not persisted), so it survives view switches and rotation within the
|
||||
// session but resets to the saved default when the app is relaunched.
|
||||
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
||||
|
||||
val state: StateFlow<AgendaUiState> =
|
||||
combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart ->
|
||||
AgendaParams(
|
||||
anchor = anchor,
|
||||
range = override ?: settings.range,
|
||||
rangeIsOverride = override != null && override != settings.range,
|
||||
weekStart = weekStart,
|
||||
showRangeBar = settings.showBar,
|
||||
)
|
||||
}
|
||||
.flatMapLatest { params ->
|
||||
val window = agendaRange(
|
||||
params.anchor,
|
||||
params.range.dayCount(params.anchor, params.weekStart) - 1,
|
||||
zone,
|
||||
)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
repository.instances(window),
|
||||
) { calendars, instances ->
|
||||
buildState(anchor, calendars, instances)
|
||||
buildState(params, calendars, instances)
|
||||
}
|
||||
}
|
||||
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
@@ -75,16 +133,48 @@ class AgendaViewModel @Inject constructor(
|
||||
_anchor.value = date
|
||||
}
|
||||
|
||||
/** Temporarily override the agenda range for this session (the bottom-left pill). */
|
||||
fun setRangeOverride(range: AgendaRange) {
|
||||
_rangeOverride.value = range
|
||||
}
|
||||
|
||||
private data class AgendaSettings(
|
||||
val range: AgendaRange,
|
||||
val showBar: Boolean,
|
||||
)
|
||||
|
||||
private data class AgendaParams(
|
||||
val anchor: LocalDate,
|
||||
val range: AgendaRange,
|
||||
val rangeIsOverride: Boolean,
|
||||
val weekStart: DayOfWeek,
|
||||
val showRangeBar: Boolean,
|
||||
)
|
||||
|
||||
private fun buildState(
|
||||
anchor: LocalDate,
|
||||
params: AgendaParams,
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): AgendaUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val days = groupAgendaDays(anchor, instances, zone)
|
||||
return AgendaUiState.Success(anchor = anchor, today = todayDate, days = days)
|
||||
val anchor = params.anchor
|
||||
val rangeEnd = anchor.plus(
|
||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
|
||||
return AgendaUiState.Success(
|
||||
anchor = anchor,
|
||||
today = todayDate,
|
||||
days = days,
|
||||
range = params.range,
|
||||
rangeIsOverride = params.rangeIsOverride,
|
||||
rangeEnd = rangeEnd,
|
||||
showRangeBar = params.showRangeBar,
|
||||
zone = zone,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import android.net.Uri
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import java.time.LocalDate
|
||||
|
||||
// SAF mime filter for the restore picker. Source apps hand `.ics` files out
|
||||
// under several mimes, so accept the common set.
|
||||
private val RESTORE_MIME_TYPES = arrayOf(
|
||||
"text/calendar",
|
||||
"application/octet-stream",
|
||||
"text/plain",
|
||||
)
|
||||
|
||||
/**
|
||||
* Backup & restore (#69): `.ics` export and restore for local calendars, with
|
||||
* optional automatic backup. Shares [CalendarsViewModel] with the manager.
|
||||
*
|
||||
* A full-screen destination hoisted in `CalendarHost`; [onBack] pops it,
|
||||
* [onImport] hands a picked file to the app's normal .ics import flow.
|
||||
*/
|
||||
@Composable
|
||||
fun BackupScreen(
|
||||
onBack: () -> Unit,
|
||||
onImport: (Uri) -> Unit,
|
||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Export covers local calendars only; managed special-dates mirrors are
|
||||
// rebuilt from contacts. Restore can target anything the import picker offers.
|
||||
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||
val canImport = calendars.any { it.isEventTarget }
|
||||
|
||||
// Exports everything eligible (null); the per-calendar selector owns its
|
||||
// own launcher.
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri -> uri?.let { viewModel.exportBackup(it, null) } }
|
||||
var showExportPicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Restore runs the picked file through the normal .ics import flow.
|
||||
val openBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
) { uri -> uri?.let(onImport) }
|
||||
|
||||
// The VM persists the write grant so background runs can keep writing.
|
||||
val pickFolder = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri -> uri?.let(viewModel::setAutoBackupFolder) }
|
||||
var showInterval by remember { mutableStateOf(false) }
|
||||
|
||||
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||
LaunchedEffect(backupResult) {
|
||||
when (val r = backupResult) {
|
||||
is BackupResult.Success -> {
|
||||
snackbarHostState.showSnackbar(
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
|
||||
),
|
||||
)
|
||||
viewModel.consumeBackupResult()
|
||||
}
|
||||
BackupResult.Failure -> {
|
||||
snackbarHostState.showSnackbar(backupFailedText)
|
||||
viewModel.consumeBackupResult()
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_backup),
|
||||
onBack = onBack,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
predictiveBack = true,
|
||||
) {
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
|
||||
if (exportable.isNotEmpty()) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_backup_action),
|
||||
position = Position.Top,
|
||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||
onClick = {
|
||||
// A single exportable calendar skips the selector.
|
||||
if (exportable.size == 1) {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
} else {
|
||||
showExportPicker = true
|
||||
}
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
summary = stringResource(R.string.calendars_restore_hint),
|
||||
position = Position.Middle,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup),
|
||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||
trailing = {
|
||||
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
|
||||
},
|
||||
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
|
||||
)
|
||||
if (autoBackup.enabled) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_folder),
|
||||
summary = rememberFolderName(autoBackup.folderUri)
|
||||
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||
position = Position.Middle,
|
||||
onClick = { runCatching { pickFolder.launch(null) } },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { showInterval = true },
|
||||
)
|
||||
HintText(backupStatusText(autoBackup.status))
|
||||
}
|
||||
} else if (canImport) {
|
||||
// Nothing to back up, but restore is still possible — don't hide
|
||||
// it behind export eligibility.
|
||||
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||
HintText(stringResource(R.string.calendars_restore_hint))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
position = Position.Alone,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showExportPicker) {
|
||||
ExportCalendarPicker(
|
||||
calendars = exportable,
|
||||
onExport = viewModel::exportBackup,
|
||||
onDismiss = { showExportPicker = false },
|
||||
)
|
||||
}
|
||||
if (showInterval) {
|
||||
BackupIntervalDialog(
|
||||
currentMinutes = autoBackup.intervalMinutes,
|
||||
onConfirm = viewModel::setAutoBackupIntervalMinutes,
|
||||
onDismiss = { showInterval = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
||||
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||
* the picked file with the chosen calendar ids.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExportCalendarPicker(
|
||||
calendars: List<CalendarSource>,
|
||||
onExport: (Uri, Set<Long>?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Deliberately not keyed on [calendars]: that list is observer-driven, so
|
||||
// keying it would reset the user's de-selections on every provider re-emit.
|
||||
var selected by rememberSaveable(
|
||||
stateSaver = listSaver(
|
||||
save = { it.toList() },
|
||||
restore = { it.toSet() },
|
||||
),
|
||||
) {
|
||||
mutableStateOf(calendars.map { it.id }.toSet())
|
||||
}
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
onExport(uri, selected)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.calendars_export_title),
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
HintText(stringResource(R.string.calendars_export_hint))
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id in selected
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
summary = calendar.description,
|
||||
position = positionOf(index, calendars.size),
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { checked ->
|
||||
selected = if (checked) selected + calendar.id else selected - calendar.id
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
selected = if (isSelected) selected - calendar.id else selected + calendar.id
|
||||
},
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
},
|
||||
enabled = selected.isNotEmpty(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.calendars_export_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
||||
@Composable
|
||||
private fun rememberFolderName(uriString: String?): String? {
|
||||
val context = LocalContext.current
|
||||
return remember(uriString) {
|
||||
uriString?.let {
|
||||
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
|
||||
@Composable
|
||||
private fun backupIntervalLabel(minutes: Long): String {
|
||||
val duration = when {
|
||||
minutes % MINUTES_PER_WEEK == 0L ->
|
||||
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
|
||||
minutes % MINUTES_PER_DAY == 0L ->
|
||||
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
|
||||
minutes % 60L == 0L ->
|
||||
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
|
||||
else ->
|
||||
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
|
||||
}
|
||||
return stringResource(R.string.calendars_auto_backup_every, duration)
|
||||
}
|
||||
|
||||
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
|
||||
@Composable
|
||||
private fun backupStatusText(status: BackupStatus): String {
|
||||
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
||||
val relative = DateUtils.getRelativeTimeSpanString(
|
||||
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
|
||||
).toString()
|
||||
return if (status.lastSuccess) {
|
||||
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
||||
} else {
|
||||
stringResource(R.string.calendars_auto_backup_status_failed, relative)
|
||||
}
|
||||
}
|
||||
|
||||
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
|
||||
@Composable
|
||||
private fun BackupIntervalDialog(
|
||||
currentMinutes: Long,
|
||||
onConfirm: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Pick the largest unit the current value divides into.
|
||||
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
|
||||
val units = stringArrayResource(R.array.backup_interval_units).toList()
|
||||
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
|
||||
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
|
||||
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
|
||||
text = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
|
||||
Spacer(Modifier.width(12.dp))
|
||||
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_auto_backup_interval_min),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
|
||||
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
|
||||
onDismiss()
|
||||
}) { Text(stringResource(R.string.reminder_custom_set)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private const val MINUTES_PER_DAY = 1_440L
|
||||
private const val MINUTES_PER_WEEK = 10_080L
|
||||
@@ -0,0 +1,71 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The one-time notice that Calendula now follows the device's per-calendar
|
||||
* visibility (#75), armed by `CalendarVisibilityReconciler`. The app does not
|
||||
* switch those calendars back on — that would un-hide them everywhere else too.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CalendarVisibilityNoticeViewModel @Inject constructor(
|
||||
private val prefs: CalendarPrefs,
|
||||
private val reconciler: CalendarVisibilityReconciler,
|
||||
) : ViewModel() {
|
||||
|
||||
/**
|
||||
* Reconcile whenever the app comes up with the calendar permission held,
|
||||
* rather than off one grant route: a permission granted on Android's
|
||||
* app-settings screen never reaches the permission screen's callback.
|
||||
*/
|
||||
fun reconcile() {
|
||||
viewModelScope.launch { reconciler.run() }
|
||||
}
|
||||
|
||||
val pending: StateFlow<Boolean> = prefs.visibilityNoticePending
|
||||
.map { it == true }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
fun dismiss() {
|
||||
viewModelScope.launch { prefs.setVisibilityNoticePending(false) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain informational dialog — one acknowledgement, nothing to decide. */
|
||||
@Composable
|
||||
fun CalendarVisibilityNoticeDialog(onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(Icons.Default.VisibilityOff, contentDescription = null) },
|
||||
title = { Text(stringResource(R.string.calendars_visibility_notice_title)) },
|
||||
text = { Text(stringResource(R.string.calendars_visibility_notice_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.dialog_ok))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -4,14 +4,13 @@ import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -21,31 +20,37 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Backup
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -59,47 +64,65 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.alpha
|
||||
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.font.FontWeight
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarStateLabel
|
||||
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
||||
import de.jeanlucmakiola.calendula.domain.isNotSynced
|
||||
import de.jeanlucmakiola.calendula.domain.orderedForManager
|
||||
import de.jeanlucmakiola.calendula.domain.stateLabels
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.common.AccountKey
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import java.time.LocalDate
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||
|
||||
/**
|
||||
* Calendar manager (reached from Settings). Lists the app's own device-only
|
||||
* calendars with create / rename / recolor / delete (via a full-screen editor),
|
||||
* and lists synced calendars read-only with a per-account "manage in the source
|
||||
* app" deep-link — the app never touches a synced calendar's server. A
|
||||
* full-screen destination; [onBack] pops it.
|
||||
* calendars with create / rename / recolor / delete, and synced calendars
|
||||
* read-only with a per-account "manage in the source app" deep-link.
|
||||
*
|
||||
* Export/import lives in its own Settings entry ([BackupScreen], #69); this
|
||||
* screen only points at it. [onBack] pops the destination.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarsScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenBackup: () -> Unit,
|
||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
|
||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||
|
||||
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
||||
// [editorSession] bumps on every open so the editor's field state resets for
|
||||
@@ -113,8 +136,9 @@ fun CalendarsScreen(
|
||||
sessionKey = editorSession,
|
||||
isNew = editorId == NEW_CALENDAR_ID,
|
||||
initialName = editing?.displayName.orEmpty(),
|
||||
initialColor = editing?.color ?: CALENDAR_COLOR_PALETTE.first(),
|
||||
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
||||
initialDescription = editing?.description.orEmpty(),
|
||||
deleteLocked = editing != null && editing.id in deleteLockedIds,
|
||||
onSave = { name, color, description ->
|
||||
val id = editorId
|
||||
if (id == null || id == NEW_CALENDAR_ID) {
|
||||
@@ -136,12 +160,12 @@ fun CalendarsScreen(
|
||||
synced = calendars.filterNot { it.isLocal },
|
||||
error = error,
|
||||
onConsumeError = viewModel::consumeError,
|
||||
backupResult = backupResult,
|
||||
onExportBackup = viewModel::exportBackup,
|
||||
onConsumeBackupResult = viewModel::consumeBackupResult,
|
||||
onOpenBackup = onOpenBackup,
|
||||
onBack = onBack,
|
||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||
onSetVisible = viewModel::setCalendarVisible,
|
||||
onSetAccountVisible = viewModel::setAccountVisible,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -152,15 +176,19 @@ private fun CalendarsList(
|
||||
synced: List<CalendarSource>,
|
||||
error: Boolean,
|
||||
onConsumeError: () -> Unit,
|
||||
backupResult: BackupResult?,
|
||||
onExportBackup: (android.net.Uri) -> Unit,
|
||||
onConsumeBackupResult: () -> Unit,
|
||||
onOpenBackup: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
onEdit: (CalendarSource) -> Unit,
|
||||
onSetVisible: (Long, Boolean) -> Unit,
|
||||
onSetAccountVisible: (Collection<Long>, Boolean) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
// Accounts the user has folded shut; empty = all expanded (keeps every
|
||||
// calendar visible by default, the section is collapsible for tidiness).
|
||||
var collapsedAccounts by remember { mutableStateOf(emptySet<AccountKey>()) }
|
||||
var localExpanded by remember { mutableStateOf(true) }
|
||||
val writeErrorText = stringResource(R.string.calendars_write_error)
|
||||
|
||||
LaunchedEffect(error) {
|
||||
@@ -170,111 +198,149 @@ private fun CalendarsList(
|
||||
}
|
||||
}
|
||||
|
||||
// SAF "create document" target for the backup file. The picked Uri is handed
|
||||
// to the VM to stream the .ics into.
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri -> uri?.let(onExportBackup) }
|
||||
|
||||
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||
LaunchedEffect(backupResult) {
|
||||
when (val r = backupResult) {
|
||||
is BackupResult.Success -> {
|
||||
snackbarHostState.showSnackbar(
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
|
||||
),
|
||||
)
|
||||
onConsumeBackupResult()
|
||||
}
|
||||
BackupResult.Failure -> {
|
||||
snackbarHostState.showSnackbar(backupFailedText)
|
||||
onConsumeBackupResult()
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.calendars_title),
|
||||
onBack = onBack,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
predictiveBack = true,
|
||||
) {
|
||||
// What the per-calendar / per-account switches below actually do.
|
||||
HintText(stringResource(R.string.calendars_visibility_hint))
|
||||
|
||||
// Local (device-only) calendars — one collapsible group. The header's
|
||||
// "+" adds a calendar; the switch enables/disables them all at once;
|
||||
// tapping a calendar row opens its editor.
|
||||
val localDisabled = local.isNotEmpty() && local.none { it.isVisibleInSystem }
|
||||
CalendarGroup(
|
||||
title = stringResource(R.string.calendars_local_header),
|
||||
expanded = localExpanded,
|
||||
bodyHasRows = local.isNotEmpty(),
|
||||
headerDisabled = localDisabled,
|
||||
leading = { Box(dimIf(localDisabled)) { LeadingAvatar(Icons.Default.PhoneAndroid) } },
|
||||
manageIcon = Icons.Default.Add,
|
||||
manageLabel = stringResource(R.string.calendars_add),
|
||||
onManage = onAdd,
|
||||
onToggleExpand = { localExpanded = !localExpanded },
|
||||
showToggleAll = local.isNotEmpty(),
|
||||
allEnabled = local.all { it.isVisibleInSystem },
|
||||
onToggleAll = { enabled -> onSetAccountVisible(local.map { it.id }, enabled) },
|
||||
) {
|
||||
// Local (device-only) calendars — the calendars the app owns. The
|
||||
// "Add calendar" entry closes the group as its final row.
|
||||
SectionHeader(stringResource(R.string.calendars_local_header))
|
||||
if (local.isEmpty()) {
|
||||
HintText(stringResource(R.string.calendars_local_empty))
|
||||
}
|
||||
val localCount = local.size + 1
|
||||
} else {
|
||||
local.forEachIndexed { index, calendar ->
|
||||
val disabled = !calendar.isVisibleInSystem
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
summary = calendar.description,
|
||||
position = positionOf(index, localCount),
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
summary = calendarRowSummary(calendar),
|
||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
dimmed = disabled,
|
||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||
trailing = {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = stringResource(R.string.calendars_edit_title),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
enabled = !disabled,
|
||||
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
||||
)
|
||||
},
|
||||
onClick = { onEdit(calendar) },
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_add),
|
||||
position = positionOf(local.size, localCount),
|
||||
leading = { AddAvatar() },
|
||||
onClick = onAdd,
|
||||
)
|
||||
|
||||
// Backup — local calendars have no sync, so a .ics export is their only
|
||||
// safety net. Offered only when there is something to back up.
|
||||
if (local.isNotEmpty()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_backup_action),
|
||||
position = Position.Alone,
|
||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||
onClick = {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Pointer to the Backup entry (#69), so it stays findable from here.
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_backup),
|
||||
summary = stringResource(R.string.settings_backup_subtitle),
|
||||
position = Position.Alone,
|
||||
leading = { LeadingAvatar(Icons.Default.Backup) },
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = onOpenBackup,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Synced calendars — read-only, grouped by account, each with a
|
||||
// per-account "manage in source app" link.
|
||||
// Synced calendars — read-only, grouped by account. Each account is a
|
||||
// collapsible group whose header opens the source app (icon) and toggles
|
||||
// all of its calendars at once (switch).
|
||||
SectionHeader(stringResource(R.string.calendars_synced_header))
|
||||
HintText(stringResource(R.string.calendars_synced_hint))
|
||||
synced
|
||||
.groupBy { it.accountName.ifBlank { it.accountType } }
|
||||
.forEach { (account, cals) ->
|
||||
AccountHeader(account = account, accountType = cals.first().accountType)
|
||||
cals.forEachIndexed { index, calendar ->
|
||||
.groupByAccount()
|
||||
.forEach { group ->
|
||||
val cals = group.calendars
|
||||
val expanded = group.key !in collapsedAccounts
|
||||
val accountType = group.accountType
|
||||
// A non-syncing calendar has no switch, so it neither counts
|
||||
// towards "the whole account is off" nor moves with toggle-all.
|
||||
val switchable = cals.filter { it.hasVisibilitySwitch }
|
||||
val accountDisabled = switchable.isNotEmpty() &&
|
||||
switchable.none { it.isVisibleInSystem }
|
||||
Spacer(Modifier.height(16.dp))
|
||||
CalendarGroup(
|
||||
title = accountGroupTitle(group),
|
||||
expanded = expanded,
|
||||
bodyHasRows = true,
|
||||
headerDisabled = accountDisabled,
|
||||
leading = { Box(dimIf(accountDisabled)) { SourceLogo(accountType) } },
|
||||
manageIcon = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
manageLabel = stringResource(R.string.calendars_manage_in_app),
|
||||
onManage = {
|
||||
runCatching { context.startActivity(sourceAppIntent(context, accountType)) }
|
||||
},
|
||||
onToggleExpand = {
|
||||
collapsedAccounts = if (expanded) {
|
||||
collapsedAccounts + group.key
|
||||
} else {
|
||||
collapsedAccounts - group.key
|
||||
}
|
||||
},
|
||||
showToggleAll = switchable.isNotEmpty(),
|
||||
allEnabled = switchable.all { it.isVisibleInSystem },
|
||||
onToggleAll = { enabled ->
|
||||
onSetAccountVisible(switchable.map { it.id }, enabled)
|
||||
},
|
||||
) {
|
||||
// Actionable calendars first; non-syncing ones at the
|
||||
// bottom, dimmed and switchless.
|
||||
val ordered = cals.orderedForManager()
|
||||
ordered.forEachIndexed { index, calendar ->
|
||||
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = positionOf(index, cals.size),
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_add_account),
|
||||
position = Position.Alone,
|
||||
leading = { AddAvatar() },
|
||||
onClick = {
|
||||
runCatching { context.startActivity(Intent(Settings.ACTION_ADD_ACCOUNT)) }
|
||||
summary = calendarRowSummary(calendar),
|
||||
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
dimmed = disabled,
|
||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||
trailing = if (calendar.hasVisibilitySwitch) {
|
||||
{
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
enabled = calendar.isVisibleInSystem,
|
||||
onToggle = { enabled ->
|
||||
onSetVisible(calendar.id, enabled)
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -288,17 +354,18 @@ private fun CalendarEditor(
|
||||
onSave: (name: String, color: Int, description: String?) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
deleteLocked: Boolean = false,
|
||||
) {
|
||||
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
|
||||
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
||||
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
BackHandler(onBack = onClose)
|
||||
val soften = LocalSoftenColors.current
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.predictiveBack(onBack = onClose)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
topBar = {
|
||||
@@ -321,11 +388,20 @@ private fun CalendarEditor(
|
||||
},
|
||||
actions = {
|
||||
if (!isNew) {
|
||||
IconButton(onClick = { confirmDelete = true }) {
|
||||
// Disabled rather than hidden while the special-dates
|
||||
// sync owns this calendar; the card below says why.
|
||||
IconButton(
|
||||
onClick = { confirmDelete = true },
|
||||
enabled = !deleteLocked,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.event_detail_delete),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
tint = if (deleteLocked) {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -354,7 +430,20 @@ private fun CalendarEditor(
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = pastelize(color, dark)) {
|
||||
if (deleteLocked) {
|
||||
EditorCard(
|
||||
icon = Icons.Default.Info,
|
||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
iconAtTop = true,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_managed_delete_locked),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
|
||||
InlineTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
@@ -375,7 +464,7 @@ private fun CalendarEditor(
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
ColorSwatchRow(
|
||||
colors = CALENDAR_COLOR_PALETTE,
|
||||
colors = CalendarColorPalette.all,
|
||||
selected = color,
|
||||
onSelect = { color = it },
|
||||
dark = dark,
|
||||
@@ -425,6 +514,48 @@ private fun CalendarEditor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's supporting line: the states that make this calendar behave unlike a
|
||||
* plain writable one (#76), then its own description.
|
||||
*/
|
||||
@Composable
|
||||
private fun calendarRowSummary(calendar: CalendarSource): String? {
|
||||
val states = calendar.stateLabels().map { label ->
|
||||
stringResource(
|
||||
when (label) {
|
||||
CalendarStateLabel.MANAGED -> R.string.calendars_state_managed
|
||||
CalendarStateLabel.READ_ONLY -> R.string.calendars_state_read_only
|
||||
CalendarStateLabel.NOT_SYNCED -> R.string.calendars_state_not_synced
|
||||
},
|
||||
)
|
||||
}
|
||||
val parts = states + listOfNotNull(calendar.description?.takeIf { it.isNotBlank() })
|
||||
return parts.joinToString(" · ").ifEmpty { null }
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-row on/off control, writing the system's device-local
|
||||
* `Calendars.VISIBLE`. Unchecked drops the calendar out of every surface and
|
||||
* stops its reminders. Carries its own content description.
|
||||
*/
|
||||
@Composable
|
||||
private fun EnableSwitch(
|
||||
calendarName: String,
|
||||
enabled: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
modifier = Modifier.semantics { contentDescription = label },
|
||||
)
|
||||
}
|
||||
|
||||
/** Fade a leading element to the M3 disabled emphasis when [disabled]. */
|
||||
private fun dimIf(disabled: Boolean): Modifier =
|
||||
if (disabled) Modifier.alpha(0.38f) else Modifier
|
||||
|
||||
/** Tonal field card matching the event editor's design (icon + content). */
|
||||
@Composable
|
||||
private fun EditorCard(
|
||||
@@ -456,89 +587,163 @@ private fun EditorCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One collapsible calendar group rendered as a connected card. The header row is
|
||||
* the card's top (tap to expand/collapse): a [leading] source mark (the account
|
||||
* app's logo, or a device chip for local), the group [title] and a trailing
|
||||
* overflow (⋮) menu holding the account-level actions — enable/disable every
|
||||
* calendar at once (when [showToggleAll]) and the manage/add action
|
||||
* ([manageIcon] / [manageLabel] → [onManage]). The group's calendars render as
|
||||
* the rows below via [body].
|
||||
*
|
||||
* The header keeps the standard row colour; the calendars it reveals sit one tone
|
||||
* darker, so the group reads as a header over nested content. Once [headerDisabled]
|
||||
* (every calendar switched off) the header fades to the disabled emphasis so the
|
||||
* whole group reads as off — not just via the menu. [bodyHasRows] is false when no
|
||||
* calendar rows follow (e.g. an empty local group), so the header stays a
|
||||
* standalone card rather than a top edge with nothing beneath it.
|
||||
*/
|
||||
@Composable
|
||||
private fun AccountHeader(account: String, accountType: String) {
|
||||
val context = LocalContext.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
private fun CalendarGroup(
|
||||
title: String,
|
||||
expanded: Boolean,
|
||||
bodyHasRows: Boolean,
|
||||
headerDisabled: Boolean,
|
||||
leading: @Composable () -> Unit,
|
||||
manageIcon: ImageVector,
|
||||
manageLabel: String,
|
||||
onManage: () -> Unit,
|
||||
onToggleExpand: () -> Unit,
|
||||
showToggleAll: Boolean,
|
||||
allEnabled: Boolean,
|
||||
onToggleAll: (Boolean) -> Unit,
|
||||
body: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = title,
|
||||
position = if (expanded && bodyHasRows) Position.Top else Position.Alone,
|
||||
dimmed = headerDisabled,
|
||||
leading = leading,
|
||||
trailing = {
|
||||
CalendarGroupMenu(
|
||||
title = title,
|
||||
showToggleAll = showToggleAll,
|
||||
allEnabled = allEnabled,
|
||||
onToggleAll = onToggleAll,
|
||||
manageIcon = manageIcon,
|
||||
manageLabel = manageLabel,
|
||||
onManage = onManage,
|
||||
)
|
||||
},
|
||||
onClick = onToggleExpand,
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
Column(content = body)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account header's overflow (⋮) menu: the two account-level actions that
|
||||
* don't fit on one line — "Enable/Disable all" (when [showToggleAll]) and the
|
||||
* manage/add action. A rounded, tonal dropdown matching the app's surfaces, with
|
||||
* the two actions divided for clear separation.
|
||||
*/
|
||||
@Composable
|
||||
private fun CalendarGroupMenu(
|
||||
title: String,
|
||||
showToggleAll: Boolean,
|
||||
allEnabled: Boolean,
|
||||
onToggleAll: (Boolean) -> Unit,
|
||||
manageIcon: ImageVector,
|
||||
manageLabel: String,
|
||||
onManage: () -> Unit,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { open = true }) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.calendars_account_menu_a11y, title),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = open,
|
||||
onDismissRequest = { open = false },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
// A distinct tone + a lifted shadow so the menu reads as floating
|
||||
// above the cards (which sit at surfaceContainerHigh) rather than
|
||||
// blending into them.
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 6.dp,
|
||||
) {
|
||||
if (showToggleAll) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = account,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
stringResource(
|
||||
if (allEnabled) R.string.calendars_disable_all
|
||||
else R.string.calendars_enable_all,
|
||||
),
|
||||
)
|
||||
OutlinedButton(onClick = {
|
||||
runCatching { context.startActivity(sourceAppIntent(context, accountType)) }
|
||||
}) {
|
||||
Icon(Icons.Default.OpenInNew, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.calendars_manage_in_app))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Neutral circular chip carrying an arbitrary icon — matches [AddAvatar]'s shape. */
|
||||
@Composable
|
||||
private fun LeadingAvatar(icon: ImageVector) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
icon,
|
||||
if (allEnabled) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
open = false
|
||||
onToggleAll(!allEnabled)
|
||||
},
|
||||
)
|
||||
HorizontalDivider(Modifier.padding(horizontal = 12.dp, vertical = 4.dp))
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(manageLabel) },
|
||||
leadingIcon = { Icon(manageIcon, contentDescription = null) },
|
||||
onClick = {
|
||||
open = false
|
||||
onManage()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Neutral circular chip with a "+" — the leading icon for add-actions. */
|
||||
@Composable
|
||||
private fun AddAvatar() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
internal fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||
// The cards' own edge, so header and group share a left margin.
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 16.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HintText(text: String) {
|
||||
internal fun HintText(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Pick the app to open for managing a synced calendar's account. The account's
|
||||
* own authenticator package (resolved from [AccountManager], no permission
|
||||
@@ -563,9 +768,3 @@ private fun sourceAppIntent(context: Context, accountType: String): Intent {
|
||||
return Intent(Settings.ACTION_SYNC_SETTINGS)
|
||||
}
|
||||
|
||||
/** Preferred app for account types whose authenticator isn't the app to open. */
|
||||
private fun curatedSourcePackage(accountType: String): String? = when {
|
||||
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
||||
else -> null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -15,6 +21,8 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -31,8 +39,10 @@ import javax.inject.Inject
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CalendarsViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val repository: CalendarRepository,
|
||||
private val icsExporter: IcsExporter,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -46,6 +56,41 @@ class CalendarsViewModel @Inject constructor(
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||
settingsPrefs.autoBackupEnabled,
|
||||
settingsPrefs.autoBackupIntervalMinutes,
|
||||
settingsPrefs.autoBackupFolderUri,
|
||||
settingsPrefs.autoBackupStatus,
|
||||
) { enabled, interval, folder, status ->
|
||||
AutoBackupUiState(enabled, interval, folder, status)
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
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()
|
||||
|
||||
@@ -61,11 +106,11 @@ class CalendarsViewModel @Inject constructor(
|
||||
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
|
||||
* in [backupResult] for a one-shot message.
|
||||
*/
|
||||
fun exportBackup(uri: Uri) {
|
||||
fun exportBackup(uri: Uri, calendarIds: Set<Long>? = null) {
|
||||
viewModelScope.launch {
|
||||
_backupResult.value = try {
|
||||
val count = withContext(io) {
|
||||
val events = repository.exportEvents()
|
||||
val events = repository.exportEvents(calendarIds)
|
||||
icsExporter.writeDocument(
|
||||
uri = uri,
|
||||
content = IcsWriter().writeCalendar(events, Clock.System.now()),
|
||||
@@ -93,6 +138,68 @@ class CalendarsViewModel @Inject constructor(
|
||||
repository.deleteCalendar(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 setCalendarVisible(id: Long, visible: Boolean) = write {
|
||||
repository.setCalendarsVisible(listOf(id), visible)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 setAccountVisible(ids: Collection<Long>, visible: Boolean) = write {
|
||||
repository.setCalendarsVisible(ids, visible)
|
||||
}
|
||||
|
||||
// --- Automatic backup (issue #8) ------------------------------------
|
||||
|
||||
fun setAutoBackupEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
settingsPrefs.setAutoBackupEnabled(enabled)
|
||||
reschedule()
|
||||
// Give immediate feedback when turning it on with a folder already set.
|
||||
if (enabled && settingsPrefs.autoBackupFolderUri.first() != null) {
|
||||
BackupScheduler.runNow(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoBackupIntervalMinutes(minutes: Long) {
|
||||
viewModelScope.launch {
|
||||
settingsPrefs.setAutoBackupIntervalMinutes(minutes)
|
||||
reschedule()
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the chosen destination folder (taking a durable write grant) and run once. */
|
||||
fun setAutoBackupFolder(uri: Uri) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||
)
|
||||
}
|
||||
settingsPrefs.setAutoBackupFolderUri(uri.toString())
|
||||
reschedule()
|
||||
if (settingsPrefs.autoBackupEnabled.first()) BackupScheduler.runNow(context)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reschedule() {
|
||||
BackupScheduler.apply(
|
||||
context = context,
|
||||
enabled = settingsPrefs.autoBackupEnabled.first(),
|
||||
intervalMinutes = settingsPrefs.autoBackupIntervalMinutes.first(),
|
||||
hasFolder = settingsPrefs.autoBackupFolderUri.first() != null,
|
||||
)
|
||||
}
|
||||
|
||||
private inline fun write(crossinline block: suspend () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
@@ -106,6 +213,14 @@ class CalendarsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Automatic-backup settings + last-run status for the Backup section. */
|
||||
data class AutoBackupUiState(
|
||||
val enabled: Boolean = false,
|
||||
val intervalMinutes: Long = SettingsPrefs.DEFAULT_BACKUP_INTERVAL,
|
||||
val folderUri: String? = null,
|
||||
val status: BackupStatus = BackupStatus(lastRun = 0L, lastSuccess = true, consecutiveFailures = 0),
|
||||
)
|
||||
|
||||
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
|
||||
sealed interface BackupResult {
|
||||
data class Success(val eventCount: Int) : BackupResult
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
|
||||
/**
|
||||
* One account's calendars, as every surface that lists calendars by account
|
||||
* shows them. An account is identified by name **and** type (#77): a Google and
|
||||
* a DAVx5 account can share an address and still be two separate accounts.
|
||||
*/
|
||||
data class CalendarAccountGroup(
|
||||
/** Stable identity: what makes two calendars belong to the same account. */
|
||||
val key: AccountKey,
|
||||
/** The account's own name, as shown when it is unambiguous. */
|
||||
val label: String,
|
||||
/** True when another group shows the same [label] under a different type. */
|
||||
val ambiguous: Boolean,
|
||||
val calendars: List<CalendarSource>,
|
||||
) {
|
||||
val accountType: String get() = key.type
|
||||
}
|
||||
|
||||
/** The pair a group is keyed on. */
|
||||
data class AccountKey(val name: String, val type: String)
|
||||
|
||||
/**
|
||||
* Group [calendars] under their owning account, preserving the provider's order
|
||||
* within each group and ordering groups by first appearance.
|
||||
*
|
||||
* The label falls back through name → type → the first calendar's own name, so
|
||||
* a calendar with no account still lands somewhere sensible.
|
||||
*/
|
||||
fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
|
||||
val grouped = groupBy { AccountKey(it.accountName, it.accountType) }
|
||||
val labels = grouped.mapValues { (key, cals) ->
|
||||
key.name.ifBlank { key.type }.ifBlank { cals.first().displayName }
|
||||
}
|
||||
val shared = labels.values.groupingBy { it }.eachCount()
|
||||
return grouped.map { (key, cals) ->
|
||||
val label = labels.getValue(key)
|
||||
CalendarAccountGroup(
|
||||
key = key,
|
||||
label = label,
|
||||
ambiguous = shared.getValue(label) > 1,
|
||||
calendars = cals,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What to write above a group: its account name, qualified with the app the
|
||||
* account comes from when another account shares the name (#77).
|
||||
*/
|
||||
@Composable
|
||||
fun accountGroupTitle(group: CalendarAccountGroup): String =
|
||||
if (!group.ambiguous) {
|
||||
group.label
|
||||
} else {
|
||||
stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType))
|
||||
}
|
||||
|
||||
/**
|
||||
* The human name of the app backing [accountType], falling back to the raw
|
||||
* account type when no installed app resolves for it.
|
||||
*/
|
||||
@Composable
|
||||
fun sourceAppName(accountType: String): String {
|
||||
val context = LocalContext.current
|
||||
return remember(accountType) {
|
||||
val pm = context.packageManager
|
||||
val packages = sourceAppPackages(context, accountType)
|
||||
packages.firstNotNullOfOrNull { pkg ->
|
||||
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
|
||||
} ?: accountType
|
||||
}
|
||||
}
|
||||
@@ -13,31 +13,18 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Soften a raw calendar color toward a pastel that fits the active theme.
|
||||
* - Keeps the hue (so users still recognise their calendars)
|
||||
* - Caps saturation so harsh provider colors stop screaming
|
||||
* - Pins value/brightness to a band that reads on both light and dark surfaces
|
||||
*/
|
||||
fun pastelize(rawArgb: Int, dark: Boolean): Color {
|
||||
val hsv = FloatArray(3)
|
||||
android.graphics.Color.colorToHSV(rawArgb, hsv)
|
||||
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
|
||||
hsv[2] = if (dark) 0.82f else 0.72f
|
||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||
}
|
||||
|
||||
/**
|
||||
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
|
||||
* in the calendar's (pastelised) colour. Shared by the calendar manager and the
|
||||
* visibility filter so they read identically.
|
||||
* in the calendar's colour — softened to a pastel, or raw when the softener is
|
||||
* off (issue #36). Shared by the calendar manager and the visibility filter so
|
||||
* they read identically.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(40.dp)
|
||||
@@ -48,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
||||
Icon(
|
||||
Icons.Filled.CalendarMonth,
|
||||
contentDescription = null,
|
||||
tint = pastelize(color, dark),
|
||||
tint = 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,16 +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
|
||||
|
||||
/**
|
||||
@@ -47,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.
|
||||
@@ -56,11 +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
|
||||
@@ -73,13 +89,13 @@ fun CalendarDrawer(
|
||||
DrawerHeader()
|
||||
|
||||
DrawerSectionHeader(stringResource(R.string.view_section))
|
||||
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
||||
viewOrder.forEachIndexed { index, view ->
|
||||
GroupedRow(
|
||||
title = stringResource(view.labelRes),
|
||||
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
||||
position = positionOf(index, viewOrder.size),
|
||||
selected = view == currentView,
|
||||
minHeight = 56.dp,
|
||||
leading = { Icon(view.icon, contentDescription = null) },
|
||||
leading = { DrawerLeadingIcon(view.icon) },
|
||||
onClick = { onSelectView(view) },
|
||||
)
|
||||
}
|
||||
@@ -89,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 },
|
||||
)
|
||||
|
||||
@@ -103,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))
|
||||
@@ -122,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(
|
||||
@@ -160,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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user