Compare commits
476
Commits
84e6402031
...
v2.22.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f07adef2f | ||
|
|
bfc05795f0 | ||
|
|
9de5f3f00d | ||
|
|
bfdbd49aa1 | ||
|
|
844a3c22ff | ||
|
|
87a08a4d24 | ||
|
|
dc0637c37c | ||
|
|
381eca6660 | ||
|
|
36018b3e52 | ||
|
|
3c0324c071 | ||
|
|
a70108dff8 | ||
|
|
83447ab68a | ||
|
|
4ac937c54f | ||
|
|
b5f28929d7 | ||
|
|
d729a928ea | ||
|
|
01a4ad742b | ||
|
|
6f77a5e12e | ||
|
|
e29c38d88c | ||
|
|
04f509fc4c | ||
|
|
b6061f1384 | ||
|
|
38c3a82b54 | ||
|
|
d3f1409503 | ||
|
|
3475a073ad | ||
|
|
643c61db65 | ||
|
|
1734b7863c | ||
|
|
d45d4f443b | ||
|
|
994fa010b8 | ||
|
|
5c20c5f6eb | ||
|
|
c4bb7ae0b2 | ||
|
|
0c985a280c | ||
|
|
17ebb9fd42 | ||
|
|
cadc2b14db | ||
|
|
1fdca903be | ||
|
|
5e7658aa80 | ||
|
|
bea31032fb | ||
|
|
f7c1dbdbc4 | ||
|
|
a1ed010963 | ||
|
|
7be6b7ac31 | ||
|
|
20c20b71ea | ||
|
|
0fb3df0b3a | ||
|
|
e10b09a9b5 | ||
|
|
806a576380 | ||
|
|
3c365c6792 | ||
|
|
4fb4a85ac4 | ||
|
|
e7312e5d6e | ||
|
|
12c2de7ed8 | ||
|
|
8a3cc95c66 | ||
|
|
9c06ffdb48 | ||
|
|
870a903015 | ||
|
|
b5768e3b17 | ||
|
|
02b33dbf07 | ||
|
|
a0c0b875f0 | ||
|
|
512bf8c746 | ||
|
|
04257e8a66 | ||
|
|
c04b2ecee2 | ||
|
|
e10fae0710 | ||
|
|
b3fe98907e | ||
|
|
b19493e925 | ||
|
|
19b4cf06b2 | ||
|
|
f9a5618b84 | ||
|
|
822a9799db | ||
|
|
eb4fd3108e | ||
|
|
4c86c261ba | ||
|
|
dc5c9db68a | ||
|
|
befcad586e | ||
|
|
9f950cba9f | ||
|
|
51a1d1e8ef | ||
|
|
631c9da0a4 | ||
|
|
5fa214b9ba | ||
|
|
eb406c3132 | ||
|
|
3a90e94958 | ||
|
|
248842871b | ||
|
|
1cab023abc | ||
|
|
9ca29bb797 | ||
|
|
13640751d2 | ||
|
|
0c7d1a97e2 | ||
|
|
d0202783d4 | ||
|
|
f72f810e4f | ||
|
|
13f4c86113 | ||
|
|
65d9f95717 | ||
|
|
19fa8203f1 | ||
|
|
0f1df4800d | ||
|
|
7351b2d35c | ||
|
|
1a66a31563 | ||
|
|
fe3c659804 | ||
|
|
a2172dce12 | ||
|
|
4099f66e91 | ||
|
|
49112b11b3 | ||
|
|
1218bb523c | ||
|
|
17600a3905 | ||
|
|
db5104a7d0 | ||
|
|
6306064fc3 | ||
|
|
d5a4761f2c | ||
|
|
66d6681798 | ||
|
|
fb61169656 | ||
|
|
62079fba0b | ||
|
|
cc0fc8edc3 | ||
|
|
62174fcd53 | ||
|
|
55c1b2ac25 | ||
|
|
0ccc610144 | ||
|
|
5d009b7dc0 | ||
|
|
28924daf02 | ||
|
|
e71fb2a625 | ||
|
|
aa5f76fc1b | ||
|
|
5b099714f6 | ||
|
|
163d821d1f | ||
|
|
fe98b1ed66 | ||
|
|
c0270ab3a0 | ||
|
|
6019d6c0b8 | ||
|
|
19bc5967a4 | ||
|
|
ddab3454bb | ||
|
|
247c9ee6cd | ||
|
|
6be170cae2 | ||
|
|
8133584f1e | ||
|
|
4cdb42afba | ||
|
|
19a4c05e93 | ||
|
|
aba06affbf | ||
|
|
2b132a5b7a | ||
|
|
52ecce03b8 | ||
|
|
0eb76db12d | ||
|
|
d03985eada | ||
|
|
34108b9c50 | ||
|
|
49a0c114c2 | ||
|
|
b1ce6f85cc | ||
|
|
ca5f87af11 | ||
|
|
7445092767 | ||
|
|
5ef66120bf | ||
|
|
bda86cd73d | ||
|
|
56953b0c50 | ||
|
|
7c8cd1e145 | ||
|
|
bce36956d1 | ||
|
|
e0fdadc7ca | ||
|
|
136167b54c | ||
|
|
02fd599e6b | ||
|
|
b0412a96f3 | ||
|
|
06dbb89868 | ||
|
|
b002f45f29 | ||
|
|
c1eb702384 | ||
|
|
307517e85a | ||
|
|
a8ba227e8b | ||
|
|
cbfdfb9710 | ||
|
|
d85003416e | ||
|
|
61c516149e | ||
|
|
560225cb8c | ||
|
|
2e8367afd4 | ||
|
|
8c40109341 | ||
|
|
4259c996b6 | ||
|
|
d4218227bd | ||
|
|
6a4c5084a5 | ||
|
|
e38b9cde1f | ||
|
|
dbae8b5bcf | ||
|
|
2d06b7ad32 | ||
|
|
10a674deba | ||
|
|
93b9aa8f34 | ||
|
|
ca826737ed | ||
|
|
5365cc67eb | ||
|
|
25e3640ba0 | ||
|
|
3f85bac74e | ||
|
|
5b83b9da73 | ||
|
|
3c8c555b94 | ||
|
|
a6a6c0c714 | ||
|
|
66dbdc8913 | ||
|
|
6a125b7c73 | ||
|
|
44233cff35 | ||
|
|
5764bd889b | ||
|
|
a8a83a39d0 | ||
|
|
9557ca73ed | ||
|
|
9767fbbbaf | ||
|
|
9054742503 | ||
|
|
3d62036d79 | ||
|
|
34a8e91ea5 | ||
|
|
0097a9c534 | ||
|
|
4e51e33356 | ||
|
|
e8657117d6 | ||
|
|
d5c53df6b5 | ||
|
|
2f4e05899a | ||
|
|
06653bf5b1 | ||
|
|
e952393116 | ||
|
|
384ba39cf9 | ||
|
|
3fca28810b | ||
|
|
c988e1f028 | ||
|
|
a3d136f55d | ||
|
|
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 | ||
|
|
3863e74857 | ||
|
|
44d056f31f | ||
|
|
5c513a1b19 | ||
|
|
037d05b171 | ||
|
|
e6dd627c85 | ||
|
|
5844539db2 | ||
|
|
2cc83d7714 | ||
|
|
dada1bbe72 | ||
|
|
e061cbe127 | ||
|
|
262b2b8273 | ||
|
|
efca0c86c4 | ||
|
|
08cd95cfba | ||
|
|
bfc9a0db39 | ||
|
|
08905990cc | ||
|
|
b5895b190e | ||
|
|
37f0e22911 | ||
|
|
4c26450ad4 | ||
|
|
e5aa90bdd6 | ||
|
|
580dc5a669 | ||
|
|
b9e800e9fc | ||
|
|
40d70d9f2b | ||
|
|
14b19cd902 | ||
|
|
67511ce9be | ||
|
|
2e90682874 | ||
|
|
2a4cb6a896 | ||
|
|
8d530c5681 | ||
|
|
11578a5588 | ||
|
|
405ff16233 | ||
|
|
69460bc35a | ||
|
|
bf10deaf5c | ||
|
|
b9e861af62 | ||
|
|
2eadda937b | ||
|
|
71b90fa184 | ||
|
|
4e9cf88bd4 | ||
|
|
b999202629 | ||
|
|
6f0e13a4a2 | ||
|
|
9ef7b33800 | ||
|
|
d04b431f2c | ||
|
|
dbeeadff80 | ||
|
|
f4a169ef5f | ||
|
|
604099b3f2 | ||
|
|
a3e6541512 |
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Something doesn't work the way it should
|
||||
title: ""
|
||||
labels:
|
||||
- bug
|
||||
---
|
||||
|
||||
### What happened
|
||||
|
||||
|
||||
### What you expected
|
||||
|
||||
|
||||
### Steps to reproduce
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Environment
|
||||
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||
- Android version:
|
||||
- Device:
|
||||
- Installed from: <!-- official F-Droid / the self-hosted repo / Google Play / Codeberg APK or Obtainium / built from source -->
|
||||
- Affected calendar: <!-- Google, CalDAV (DAVx5, Nextcloud, …), on-device/local,
|
||||
subscribed/WebCal, birthdays. Provider behaviour differs
|
||||
a lot per account type, so this often points straight at
|
||||
the cause -->
|
||||
- Time zone: <!-- only if the problem involves dates or all-day events -->
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Kept enabled so anything that doesn't fit the four templates still has a way
|
||||
# in (the `ToDo` label exists for exactly those).
|
||||
blank_issues_enabled: true
|
||||
|
||||
contact_links:
|
||||
- name: Translate Calendula
|
||||
url: https://weblate.dev.jeanlucmakiola.de/engage/calendula/
|
||||
about: >-
|
||||
Translations are managed on Weblate. It owns every values-* file, so a
|
||||
hand-edited translation gets overwritten on the next sync.
|
||||
No coding needed: pick or request a language and translate in the browser.
|
||||
|
||||
- name: Contributing guide
|
||||
url: https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
|
||||
about: >-
|
||||
Before opening a pull request: the issue-first workflow, which release
|
||||
branch to target, how to build (there's a submodule), and the
|
||||
architectural rules a change is reviewed against.
|
||||
@@ -1,20 +1,20 @@
|
||||
---
|
||||
name: Crash report
|
||||
about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form.
|
||||
about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash). It copies the report to your clipboard and prefills this form.
|
||||
title: "Crash: "
|
||||
labels:
|
||||
- bug
|
||||
- crash
|
||||
- priority:high
|
||||
- priority/high
|
||||
---
|
||||
|
||||
<!--
|
||||
Thanks for reporting a crash in Calendula!
|
||||
|
||||
If the app prefilled this for you, the crash report is already below — just add
|
||||
If the app prefilled this for you, the crash report is already below. Add
|
||||
what you were doing and submit. Otherwise, paste the report from your clipboard
|
||||
into the code block. The report contains only app/Android/device versions and the
|
||||
stack trace — no personal data or calendar content.
|
||||
stack trace, with no personal data or calendar content.
|
||||
-->
|
||||
|
||||
### What happened
|
||||
+1
-1
@@ -9,7 +9,7 @@ labels:
|
||||
### What would you like Calendula to do?
|
||||
|
||||
|
||||
### Why — what problem does it solve?
|
||||
### What problem does it solve?
|
||||
|
||||
|
||||
### Anything else
|
||||
@@ -0,0 +1,41 @@
|
||||
<!--
|
||||
Thanks for contributing to Calendula!
|
||||
|
||||
Please skim CONTRIBUTING.md if you haven't:
|
||||
https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
|
||||
|
||||
Two things it's easy to get wrong:
|
||||
• Features need a discussed issue first. An undiscussed feature PR may be
|
||||
closed unmerged even when the code is good.
|
||||
• Target the release branch for your issue's milestone (milestone 2.18.0 →
|
||||
release/v2.18.0), not main. If you targeted main, just say so below and it
|
||||
will be retargeted.
|
||||
-->
|
||||
|
||||
### What this changes
|
||||
|
||||
|
||||
### Why
|
||||
|
||||
<!-- Closes #123: link the issue this implements or fixes. -->
|
||||
|
||||
|
||||
### How it was tested
|
||||
|
||||
<!--
|
||||
Which of these ran green, and anything you exercised by hand. On-device notes
|
||||
are especially useful for UI changes.
|
||||
|
||||
./gradlew lint test assembleDebug
|
||||
python3 scripts/check_translations.py
|
||||
-->
|
||||
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] There's an issue for this, and (for a feature) it got a go-ahead
|
||||
- [ ] Targeting the release branch for that issue's milestone (or `main`, noted above)
|
||||
- [ ] `./gradlew lint test assembleDebug` passes locally
|
||||
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
|
||||
- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible
|
||||
- [ ] No planning or design documents committed
|
||||
@@ -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,48 @@ 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`.
|
||||
# Play rejects a "What's New" over 500 characters, which would fail the
|
||||
# upload after the release had already shipped everywhere else. Cheap, so
|
||||
# it runs on every PR rather than only on the release merge.
|
||||
- name: Changelog length invariant
|
||||
run: bash scripts/check_changelog_lengths.sh
|
||||
|
||||
# The whole listing goes to Play with every release; a field over its cap
|
||||
# or an image Play rejects would fail that upload.
|
||||
- name: Store listing invariant
|
||||
run: |
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update && apt-get install -y python3
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache python3
|
||||
fi
|
||||
fi
|
||||
python3 scripts/check_store_listing.py
|
||||
|
||||
# Decide whether anything that affects the app build changed. Docs, store
|
||||
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
||||
# the SDK + Gradle work below but still report a green `ci`.
|
||||
- name: Classify change scope
|
||||
id: scope
|
||||
env:
|
||||
# Deliberately a skip-list, not a build-list: a path nobody thought
|
||||
# about defaults to building. Only paths the Gradle build provably
|
||||
# never reads belong here — note that the workflows themselves, the
|
||||
# `.gitmodules` submodule pointer and `scripts/` are *not* in it.
|
||||
SKIP_RE: '(\.md$|^docs/|^fastlane/|^fdroid-metadata/|^licenses/|^\.planning/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^renovate\.json5$|^LICENSE$)'
|
||||
run: |
|
||||
set -e
|
||||
BASE="${{ github.base_ref }}"
|
||||
# Normally the bare branch name; tolerate a full ref, which would
|
||||
# otherwise make the merge-base lookup fail and quietly degrade this
|
||||
# guard into "always build".
|
||||
BASE="${BASE#refs/heads/}"
|
||||
if [ -z "$BASE" ]; then
|
||||
echo "No base branch on this event — running the full build to be safe."
|
||||
echo "code=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Full (not --depth=1) base fetch so the merge-base is present even when
|
||||
# the PR branch forked several commits back; a shallow tip has no merge
|
||||
# base with a divergent branch and `git diff base...HEAD` aborts.
|
||||
@@ -57,11 +92,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 +110,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 }}
|
||||
@@ -5,3 +5,4 @@
|
||||
*.jpg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
app/src/test/resources/ics/*.ics -text
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Something doesn't work the way it should
|
||||
title: ""
|
||||
labels:
|
||||
- bug
|
||||
---
|
||||
|
||||
### What happened
|
||||
|
||||
|
||||
### What you expected
|
||||
|
||||
|
||||
### Steps to reproduce
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Environment
|
||||
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||
- Android version:
|
||||
- Device:
|
||||
+324
-18
@@ -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."
|
||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No tag for v$VERSION yet — cutting the release."
|
||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
#
|
||||
# Anything other than a clean 200/404 is treated as fatal rather than
|
||||
# as "no tag". A Codeberg outage or a network blip would otherwise
|
||||
# read as absent and re-cut a release that has already shipped —
|
||||
# republishing to F-Droid and Play. Failing here is recoverable; a
|
||||
# duplicate release is not.
|
||||
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
|
||||
case "$STATUS" in
|
||||
200)
|
||||
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
|
||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
404)
|
||||
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
|
||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
|
||||
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
||||
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
||||
@@ -82,6 +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,267 @@ 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. `production` — the release itself is already the
|
||||
# gate (a bumped versionName only reaches main after on-device review), so
|
||||
# a second manual promotion in the Play Console bought nothing but delay.
|
||||
# Override with the PLAY_TRACK repo variable to stage a release instead.
|
||||
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'production' }}
|
||||
PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }}
|
||||
# Set PLAY_DRY_RUN=true to validate the edit against the API and discard
|
||||
# it instead of committing — used to rehearse the first upload.
|
||||
PLAY_DRY_RUN: ${{ vars.PLAY_DRY_RUN || 'false' }}
|
||||
BUNDLE_PATH: vendor/bundle
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Skip cleanly (not fatally) when Play isn't configured yet, so the rest
|
||||
# of the release pipeline keeps working during setup — same contract as
|
||||
# the Codeberg mirror step.
|
||||
- name: Write the Play service-account key
|
||||
id: key
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${PLAY_SERVICE_ACCOUNT_JSON:-}" ]; then
|
||||
echo "PLAY_SERVICE_ACCOUNT_JSON not set — skipping the Play upload."
|
||||
echo "configured=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
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
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "floret-kit"]
|
||||
path = floret-kit
|
||||
url = https://codeberg.org/jlmakiola/floret-kit.git
|
||||
+29
-8
@@ -17,9 +17,10 @@ re-inventing the calendar sync stack — leave that to DAVx5 and the system.
|
||||
## Current Milestone
|
||||
|
||||
Milestones 1 (read, v1.0) and 2 (write support, v1.1–v2.0.0 incl. reminder
|
||||
delivery) are **complete** — v2.0.0 shipped 2026-06-11. Next is v3.0
|
||||
(power-user features) plus an undecided "Locations & People" idea backlog;
|
||||
see `ROADMAP.md`.
|
||||
delivery) are **complete** — v2.0.0 shipped 2026-06-11. Everything since is
|
||||
tracked as issues and milestones on Codeberg:
|
||||
<https://codeberg.org/jlmakiola/calendula/milestones>. A milestone maps to its
|
||||
`release/vX.Y.Z` branch.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -27,10 +28,26 @@ Kotlin 2.3.21 (paired with KSP 2.3.9 — Kotlin 2.4.0 has no KSP release
|
||||
yet, do not upgrade until one ships). Jetpack Compose + Material 3
|
||||
Expressive 1.5.0-alpha21 (alpha is intentional — Expressive APIs only
|
||||
live in the 1.5 alpha line). Hilt 2.59.2, DataStore. Gradle Kotlin DSL
|
||||
with Version Catalog. AGP 9.1.1, Gradle 9.5.1. JVM target 17.
|
||||
with Version Catalog. AGP 9.2.1, Gradle 9.5.1. JVM target 17 (exactly — AGP
|
||||
requires it).
|
||||
|
||||
Android-only (minSdk 29, targetSdk 36). No iOS. No `INTERNET` permission —
|
||||
any feature that would need one is an explicit product decision first.
|
||||
The shared Material 3 Expressive kit, **floret-kit**, is a git submodule wired
|
||||
in as a Gradle composite build, so it compiles from source rather than resolving
|
||||
as a dependency.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Platform:** Android-only, Android 10+ (minSdk 29), targetSdk 36. No iOS.
|
||||
- **Offline-first:** all data lives in `CalendarContract` — no app database, no
|
||||
sync stack. No `INTERNET` permission; any feature needing one is an explicit
|
||||
product decision first.
|
||||
- **Privacy:** zero telemetry, zero analytics.
|
||||
- **i18n:** German + English from day one; further languages come from community
|
||||
translators via the self-hosted Weblate, which owns every `values-*` file.
|
||||
- **Tests + CI from day one**, JVM-first.
|
||||
- **Reproducible release builds**, so the official F-Droid repo can verify the
|
||||
published binary against a from-source rebuild.
|
||||
- **Licence:** MIT.
|
||||
|
||||
## Naming
|
||||
|
||||
@@ -40,5 +57,9 @@ shows a stylized "1" on a slate squircle.
|
||||
|
||||
## Source
|
||||
|
||||
Hosted on self-hosted Gitea, released through self-hosted F-Droid repo on
|
||||
Hetzner. Same infrastructure as `HouseHoldKeaper`.
|
||||
**Codeberg (`jlmakiola/calendula`) is canonical** — git, issues, PRs, tags and
|
||||
releases, plus contributor CI. The self-hosted Gitea instance is build
|
||||
infrastructure only: it holds the signing key, runs the release pipeline, and
|
||||
publishes the self-hosted F-Droid repo on Hetzner. Codeberg push-mirrors `main`
|
||||
and tags to Gitea, where a bumped `versionName` triggers the release. Also
|
||||
published to the official F-Droid repo. See `docs/RELEASING.md`.
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# Calendula — Requirements
|
||||
|
||||
See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
|
||||
|
||||
## V1 Scope (Variant "B") — shipped in full (v1.0.0, 2026-06-11)
|
||||
|
||||
- [x] Foundation & CI infrastructure — v0.1.0 (2026-06-08)
|
||||
- [x] Data Layer over `CalendarContract`
|
||||
- [x] Permission flow (`READ_CALENDAR`)
|
||||
- [x] Month view (S1)
|
||||
- [x] Week view (S2)
|
||||
- [x] Day view (S3)
|
||||
- [x] Event Detail Sheet (S4) — became a full screen, plus full event read (v0.6)
|
||||
- [x] Multi-Calendar Filter (M3)
|
||||
- [x] Today button (M2) — shipped v0.5; Jump-to-Date **cut from scope**
|
||||
- [x] View-Switcher (M1)
|
||||
- [x] Settings screen (M4)
|
||||
- [x] Empty / no-permission / no-calendars states
|
||||
- [x] German + English localization
|
||||
- [x] Loading/Failure/Success states per screen (architectural pattern)
|
||||
|
||||
## V2 Scope — write support, shipped in full (v2.0.0, 2026-06-11)
|
||||
|
||||
- [x] Write foundation: `WRITE_CALENDAR`, read-only-calendar detection, delete (v1.1)
|
||||
- [x] Create event: form, FAB, last-used calendar (v1.2; polish v1.2.1)
|
||||
- [x] Edit event: shared form, scoped recurring writes, recurrence picker (v1.3)
|
||||
- [x] Reminder notifications (v1.4) — **reversal of the original
|
||||
"system handles reminders" assumption:** Calendula targets
|
||||
sole-calendar-app users, so it posts reminder notifications itself
|
||||
(Etar model), incl. `POST_NOTIFICATIONS` onboarding
|
||||
- [x] Conflict dialog on save + store polish (v2.0)
|
||||
- Quick-add — **cut from scope** (the prefilled form covers it)
|
||||
- Calendar switching while editing — moved to v3 backlog
|
||||
|
||||
### Out of Scope (V3+)
|
||||
|
||||
- Home-screen widget
|
||||
- Full-text search
|
||||
- Tablet/foldable-specific layouts
|
||||
- Locations & People ideas (contact picker, OSM autocomplete) — see
|
||||
`ROADMAP.md` idea backlog, undecided
|
||||
- iOS support (Android-only by design)
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Tech stack:** Kotlin + Jetpack Compose + Material 3 Expressive, Hilt, DataStore
|
||||
- **Tech stack pin:** Hilt 2.59.2 + KSP 2.3.9; Kotlin 2.3.21 (KSP for Kotlin 2.4.0 not released yet). Material 3 pinned to `1.5.0-alpha21` (Expressive APIs only exist in alpha). Re-evaluate when KSP/Material3 stable land.
|
||||
- **Platform:** Android 10+ (API 29 minimum), Android 16 (API 36) target
|
||||
- **Offline-first:** all data lives in `CalendarContract`; no app-side network
|
||||
- **Privacy:** zero telemetry, no analytics
|
||||
- **i18n:** German + English from day one
|
||||
- **Tests + CI from day one**
|
||||
- **License:** MIT
|
||||
@@ -1,587 +0,0 @@
|
||||
# Calendula — Roadmap
|
||||
|
||||
## v0.x — Pre-Release
|
||||
|
||||
| Version | Milestone | Status |
|
||||
|---|---|---|
|
||||
| v0.1 | Foundation & CI | complete |
|
||||
| v0.2 | Data Layer & Permission Flow | complete |
|
||||
| v0.3 | Month + Week + Day views, view switcher | complete |
|
||||
| v0.4 | Event Detail (S4) + humanized recurrence | complete |
|
||||
| v0.5 | Calendar filter (M3) + Settings (M4) | complete |
|
||||
| v0.6 | Full event read — surface every readable field | complete |
|
||||
| v1.0 | First public release — polish pass, F-Droid | complete |
|
||||
|
||||
Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and
|
||||
Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5.
|
||||
|
||||
Jump-to-date (the date-picker half of M2) was **cut from scope** and will not
|
||||
ship. The "Today" half of M2 already shipped in v0.5 (drawer entry).
|
||||
|
||||
## v0.6 — Full event read
|
||||
|
||||
Round out the read-only model so a detail view shows everything the system
|
||||
actually stores, before write support starts. Scope = `CalendarContract`
|
||||
columns we don't yet read/display:
|
||||
|
||||
- **Reminders** (`VALARM`) — read `CalendarContract.Reminders`, list lead times
|
||||
- **Status** — Confirmed / Tentative / Cancelled (cancelled shown struck-through)
|
||||
- **Availability** (`TRANSP`) — Free / Busy chip
|
||||
- **Attendee extras** — role (required / optional / organizer) + the user's own
|
||||
`SELF_ATTENDEE_STATUS`
|
||||
- **Timezone** (`EVENT_TIMEZONE`) — shown only when it differs from the device zone
|
||||
- **URL** — ~~tappable link card~~ **cut**: `CalendarContract` exposes no
|
||||
`Events.URL` column (only `CUSTOM_APP_URI`, an originating-app deep-link).
|
||||
URLs are instead surfaced by linkifying the description text
|
||||
- **Access level / class** (private / confidential) — small chip (optional, trivial)
|
||||
|
||||
All of the above shipped in v0.6.0 (2026-06-11).
|
||||
|
||||
Deliberately out of v0.6:
|
||||
- Recurrence exception / modified-occurrence badges — `Instances` already
|
||||
resolves correct per-occurrence times for display; this only matters for
|
||||
editing, so it folds into v2
|
||||
- `CATEGORIES`, `ATTACH` — not reliably exposed by `CalendarContract`
|
||||
(provider limitation, not our choice)
|
||||
|
||||
## v1.0 — First Public Release — shipped 2026-06-11
|
||||
|
||||
All V1 features shipped, polished, on F-Droid. Read-only calendar. Cut directly
|
||||
after v0.6 (full event read) plus the onboarding-screen polish pass.
|
||||
|
||||
### Polish backlog (pre-1.0)
|
||||
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
|
||||
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
|
||||
|
||||
## v2.0 — Write Support (complete, shipped 2026-06-11)
|
||||
|
||||
Delivered in four releasable slices (plan:
|
||||
`docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a
|
||||
guide here, not a contract — scope per slice is decided as we go.
|
||||
|
||||
| Version | Milestone | Status |
|
||||
|---|---|---|
|
||||
| v1.1 | Write foundation — `WRITE_CALENDAR`, read-only-calendar detection, delete (series + single occurrence) | complete (shipped 2026-06-11) |
|
||||
| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
|
||||
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
|
||||
| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) |
|
||||
| v1.4 | Reminder notifications — see below | complete (shipped 2026-06-11) |
|
||||
| v2.0 | Conflict dialog, polish pass (store copy refresh, F-Droid screenshots), release | complete (shipped 2026-06-11) |
|
||||
|
||||
v2.0 scope was re-cut on 2026-06-11, after v1.4:
|
||||
- **Occurrence edit** already shipped early, in v1.3.
|
||||
- **Quick-add** is **cut from scope**: the full form already opens prefilled
|
||||
(visible day, last-used calendar, optional fields hidden), so the sheet
|
||||
would only save one screen transition while adding a second create-surface
|
||||
to maintain. Revisit only if real-world feedback says creation feels heavy.
|
||||
- **Calendar switching while editing** moves to the v3 backlog (sync-adapter
|
||||
minefield: `CALENDAR_ID` is sync-adapter-owned, AOSP locks the field; an
|
||||
honest implementation is copy+delete like Google Calendar, with sync-identity
|
||||
and attendee side effects).
|
||||
- **Conflict dialog** stays (plan 03, decision 5): on save, compare against
|
||||
the row as it was when the form loaded; on external change, ask
|
||||
overwrite / discard. Closes the silent-clobber gap on synced calendars.
|
||||
|
||||
## v1.4 — Reminder Notifications
|
||||
|
||||
**Essential**, not nice-to-have: Calendula targets users for whom it is their
|
||||
*only* calendar app, so reminder delivery can't be delegated to Google/OEM
|
||||
Calendar. The calendar provider schedules reminders and broadcasts
|
||||
`android.intent.action.EVENT_REMINDER`, but it does **not** post the visible
|
||||
notification — a calendar app must. We become that app (the Etar model).
|
||||
|
||||
Scope:
|
||||
- Manifest-registered `BroadcastReceiver` for `EVENT_REMINDER`
|
||||
(data scheme `content://com.android.calendar`) — wakes us at reminder time,
|
||||
no foreground service.
|
||||
- Read `CalendarContract.CalendarAlerts` / `Reminders`, filter to
|
||||
`METHOD_ALERT` / `METHOD_DEFAULT` (skip `METHOD_EMAIL`); post on a dedicated
|
||||
notification channel; tap opens event detail.
|
||||
- `POST_NOTIFICATIONS` runtime permission (API 33+) — requested in onboarding.
|
||||
- Onboarding step: (a) request `POST_NOTIFICATIONS`, (b) in-app reminders
|
||||
toggle, **default ON**, with copy warning that a second calendar app with
|
||||
notifications on will cause duplicate reminders. Mirrored into Settings
|
||||
(reversible).
|
||||
|
||||
Deliberately deferred (add only if needed):
|
||||
- Snooze / dismiss notification actions (Etar has them)
|
||||
- Battery-optimization exemption prompt for delivery reliability
|
||||
|
||||
## v2.1 — Month event grid + drawer view tabs (shipped 2026-06-15)
|
||||
|
||||
- Month grid shows real events as continuous multi-day bars (not just dots)
|
||||
- View section in the navigation drawer to switch Month / Week / Day
|
||||
- Fix: text cursor no longer jumps in event text fields
|
||||
|
||||
## v2.2 — Tap-to-create + local calendar management (shipped 2026-06-16)
|
||||
|
||||
- Tap an empty slot in day/week → create form prefilled with that day + the
|
||||
tapped hour (snapped to the hour, 1 h long)
|
||||
- Local (device-only) calendar management in a full-screen editor from
|
||||
Settings → Calendars: create / rename / recolor / delete, with name,
|
||||
pastel-previewed colour, and description (stored in `CAL_SYNC1`)
|
||||
- Synced calendars listed read-only, grouped by account, each with a
|
||||
per-account "manage in source app" deep-link (resolved from the account's
|
||||
authenticator — DAVx5/ICSx5/…) + an add-account shortcut
|
||||
- Shared `InlineTextField` extracted to `ui.common` (event form + calendar
|
||||
editor share one input style)
|
||||
|
||||
## v2.3 — Material 3 grouped-list redesign (shipped 2026-06-16)
|
||||
|
||||
A structural + visual pass adopting one shared blueprint (modelled on the ReFra
|
||||
gallery app) across Settings, the calendar manager and the navigation drawer.
|
||||
|
||||
- Shared `ui/common/GroupedList.kt`: `CollapsingScaffold` (a `LargeTopAppBar`
|
||||
whose title collapses on scroll) + `GroupedRow` (Position-based corner
|
||||
grouping, press-animated corners, `selected` + `minHeight` knobs).
|
||||
- Settings: category hub with About card on top and sliding sub-pages
|
||||
(Appearance / New event form / Notifications); theme/week-start/language
|
||||
pickers moved from `DropdownMenu` to OptionCard dialogs; token-based icon
|
||||
chips; `ic_gitea.xml` for the About "Source" button.
|
||||
- Calendar manager + drawer restyled to match; shared `CalendarColorChip`;
|
||||
drawer scrolls as one with the active view highlighted.
|
||||
- Cards use `surfaceContainerHigh` for readable contrast.
|
||||
- Donate button on the About card deferred (target TBD).
|
||||
|
||||
---
|
||||
|
||||
# Backlog (theme-based, post-v2.1)
|
||||
|
||||
The old v3.0 / "daily-driver polish" / "Locations & People" lists are
|
||||
consolidated here by theme. Within a group, **(in progress)** /
|
||||
**(next)** mark what is being or about to be worked; everything else is an
|
||||
approved-but-unscheduled idea unless tagged **(idea)** /
|
||||
**(go/no-go)** / **(rejected)**. Order across groups is not a commitment.
|
||||
|
||||
## Near-term sequence (ranked, 2026-06-16)
|
||||
|
||||
The theme groups below are the full menu; this is the committed *order* for
|
||||
the next stretch. Ranking favours finishing the current create/edit + calendar
|
||||
arc before opening new fronts, then cheap-relative-to-value items and ones that
|
||||
unblock a later item. Order is a plan, not a contract — revisit after each lands.
|
||||
|
||||
**Tier 1 — finish the current arc (create/edit + calendars)**
|
||||
1. Tap-to-create in day/week *(shipped v2.2.0)* — prefilled create from an empty slot
|
||||
2. Local calendar management + "manage in source app" deep-links *(shipped v2.2.0)*
|
||||
3. ~~Settings redesign & restructure~~ *(shipped v2.3.0 — grew into the full
|
||||
grouped-list blueprint across Settings + calendars + drawer; see "v2.3"
|
||||
above)*
|
||||
4. ~~Per-event color~~ *(shipped v2.4.0)* — palette calendars write
|
||||
`EVENT_COLOR_KEY` (sync-safe); local/opted-in calendars write a raw
|
||||
`EVENT_COLOR`; off-by-default setting for no-palette synced calendars
|
||||
Tier 1's create/edit + calendars arc is effectively closed. **Duplicate event**
|
||||
was deprioritised (2026-06-17) as low-importance and dropped to the bottom of
|
||||
the sequence; the next item is now **Jump-to-date** (formerly Tier 2).
|
||||
|
||||
(Tier 2+ numbering below shifts accordingly; ranking unchanged.)
|
||||
|
||||
### Settings redesign & restructure *(shipped v2.3.0)*
|
||||
|
||||
The original scope below is kept as a record; the implementation expanded from a
|
||||
sub-screen restructure into the shared grouped-list blueprint (see "v2.3" above).
|
||||
|
||||
The settings screen has grown into a flat vertical scroll of divider-separated
|
||||
sections (Appearance, Event form, Notifications, Calendars, Language, About) and
|
||||
will keep accreting rows (per-event-color defaults, default reminder, more
|
||||
calendar entries are all queued). It needs structure before it gets unwieldy.
|
||||
|
||||
**Decided (2026-06-16): sub-screens**, not flat-but-carded. The top level
|
||||
becomes a category list; each category opens its own destination. More
|
||||
M3-idiomatic for a settings surface that will keep growing, and it mirrors the
|
||||
existing Calendars row, which already navigates out to its own screen.
|
||||
|
||||
Structure — top-level settings list → category destinations:
|
||||
- **Appearance** → theme, dynamic colour, week start
|
||||
- **Event form** → the 6 default-field toggles + the hint text
|
||||
- **Notifications** → reminders toggle (POST_NOTIFICATIONS flow stays)
|
||||
- **Calendars** → already its own screen (`CalendarsScreen`); just becomes a
|
||||
peer category row, no change to that screen
|
||||
- **Language** → single control; keep as a top-level row that opens an
|
||||
OptionCard directly (a whole sub-screen for one choice is overkill)
|
||||
- **About** → kept inline on the top-level list as a card (read-only info,
|
||||
not worth a navigation hop). Card layout, top → bottom:
|
||||
- **Identity** — app logo + name "Calendula", with "by Jean-Luc Makiola"
|
||||
as a subtitle beneath the name
|
||||
- **Action buttons** (small, button-styled, sit in a row):
|
||||
- **Source** — Gitea logo, opens the repo (`about_source_url`)
|
||||
- **License** — opens the LICENSE file on Gitea
|
||||
- **Donate** *(tentative)* — sits next to Source; target TBD (decide
|
||||
before building: Liberapay / Ko-fi / Gitea sponsor / etc.)
|
||||
- **Version** — small version number at the bottom of the card
|
||||
|
||||
Scope:
|
||||
- **Navigation** — add the settings sub-screen destinations alongside the
|
||||
existing settings/calendars routes in `CalendarHost`; back pops to the
|
||||
settings list (mind the existing `BackHandler` that guards against falling
|
||||
through to the activity).
|
||||
- **Fix the dialog-pattern violation** — theme, week-start and language use
|
||||
`DropdownMenu`; the project default is the full-width tonal OptionCard modal
|
||||
(radio/dropdown/text-list dialogs are banned, see
|
||||
`option-card-modal-style-default`). Migrate these selectors to OptionCard.
|
||||
- **Visual pass** — top-level category rows with leading icons; consistent
|
||||
spacing and row affordances aligned with the event-form card design system.
|
||||
|
||||
Out of scope (no new settings *features* here) — this is a structure + style
|
||||
pass on the existing controls; new toggles ride in with their own features.
|
||||
|
||||
**Tier 2 — navigation & daily-driver completeness**
|
||||
5. ~~Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap~~ *(done, v2.5.0)*
|
||||
6. ~~Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget~~ *(done, v2.5.0)*
|
||||
|
||||
**Tier 3 — platform reach (depends on Tier 2)**
|
||||
7. ~~Home-screen widget — built on the agenda data source from #6~~ *(done, v2.5.0 — agenda + month widgets)*
|
||||
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; ~~quick-settings tile~~ *(done, v2.8.0 — "New event" QS tile)*
|
||||
|
||||
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
|
||||
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
|
||||
default reminder **+ per-calendar override**, bundled with battery-exemption
|
||||
hardening. Full sketch in "Reminders — defaults & delivery reliability" below.
|
||||
10. **The `.ics` engine — export + import** *(shipped v2.7.0, 2026-06-18)* — one
|
||||
hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`),
|
||||
four surfaces all shipped: single-event share + whole-calendar backup
|
||||
(export), open-`.ics`→form + whole-calendar bulk import (import). Closed the
|
||||
device-local-calendar data-loss gap (#10/#11 merged here). Built as two
|
||||
sequential branches: `feat/ics-export` (write side + UID-on-create precursor)
|
||||
then `feat/ics-import` (parser, restore, dedup by UID). Import is
|
||||
liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` / `RECURRENCE-ID`
|
||||
/ guest lists it can't model. Plans:
|
||||
`docs/superpowers/plans/2026-06-18-05-ics-export.md` + `…-06-ics-import.md`.
|
||||
11. **Snooze / dismiss notification actions** *(merged into release/v2.8.0)* —
|
||||
followed the `.ics` work; inherits v2.6's deferred exact-alarm/WorkManager
|
||||
decision (snooze must re-fire an alarm).
|
||||
|
||||
**Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)*
|
||||
12. **Attendee editing** *(shipped — merged into release/v2.8.0, `feat/attendee-editing`
|
||||
MR !29 / commit `b0f34ff`, 2026-06-22)* — closed the last big read-only gap in
|
||||
the event model: attendees were already *read* (queried, mapped, shown on the
|
||||
detail screen since v0.6) and are now *writable* from the form. An attendees
|
||||
section on `EventEditScreen` / `EventForm` adds by typed email **or** the
|
||||
no-permission contact picker, edits/removes rows, and sets role (required /
|
||||
optional) — writing `CalendarContract.Attendees` rows on insert + dirty-checked
|
||||
update, mirroring the reminders-diff pattern. The sync-adapter *invitation*
|
||||
caveat was **settled record-only** (Calendula never sends invites; the backend
|
||||
decides delivery) — full sketch + decision in "Attendee editing" under Locations
|
||||
& People below.
|
||||
|
||||
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
|
||||
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
|
||||
- Locations & People — the no-permission contact pickers already shipped (location v2.8.0, attendee email v2.8.0); what remains gated is OSM autocomplete (needs INTERNET)
|
||||
- Move event to another calendar — sync-adapter minefield (copy+delete model)
|
||||
|
||||
**Bottom — deprioritised, not important**
|
||||
- Duplicate event (detail action → prefilled create form) — moved here
|
||||
2026-06-17; cheap but low value, pick up only if asked
|
||||
|
||||
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts.
|
||||
Pulled in opportunistically, not sequenced.
|
||||
|
||||
Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics`
|
||||
export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected),
|
||||
and Tier 5 #12 — attendee editing — shipped in release/v2.8.0, closing the last
|
||||
read-only gap in the event model. v2.8.0 also cleared most Tier 2/3 leftovers —
|
||||
full-text search, the "New event" Quick Settings tile, and the now-line all
|
||||
shipped there. **No tier work is currently committed as next;** the remaining
|
||||
candidates are unscheduled theme-group ideas (pinch-to-zoom, tablet/foldable,
|
||||
accessibility pass) plus the gated go/no-go items.
|
||||
|
||||
## Navigation & views
|
||||
|
||||
- ~~Tap an empty slot in day/week → create form prefilled with that
|
||||
date+time, snapped to the hour~~ **shipped v2.2.0** (long-press variant
|
||||
not added — single tap covers it)
|
||||
- Agenda view (fourth view: upcoming events grouped by day; also the
|
||||
natural data source for a future widget)
|
||||
- Jump to date — drawer date picker (un-cut from V1)
|
||||
- ~~Current-time "now" line in day/week~~ **shipped v2.8.0**
|
||||
- Week numbers in the **month** grid — **rejected** (owner decision): clutters
|
||||
the view and shrinks the day cells; the badge stays week-view-only.
|
||||
- Pinch-to-zoom time scale in day/week
|
||||
- Tablet / foldable layouts *(was v3.0)*
|
||||
- ~~Full-text search~~ **shipped v2.8.0** — for a daily driver with real event
|
||||
history, finding an event is core completeness, not optional.
|
||||
|
||||
## Event editing & creation
|
||||
|
||||
- Duplicate event (detail action → prefilled create form)
|
||||
- ~~**Per-event color**~~ *(shipped v2.4.0)* — `EVENT_COLOR` / `EVENT_COLOR_KEY`
|
||||
from the calendar's color list (`Colors` table, `TYPE_EVENT`), OptionCard
|
||||
picker in the form, falling back to the calendar color when unset. Reused the
|
||||
color-picker component and palette plumbing from local calendar management and
|
||||
finished the create/edit theme.
|
||||
|
||||
## Calendars & accounts
|
||||
|
||||
- ~~Create / manage local (device-only) calendars~~ **shipped v2.2.0** —
|
||||
name + color + description; rename / recolor / delete the calendars the app
|
||||
owns. Inserted under `ACCOUNT_TYPE_LOCAL` as a sync adapter; description in
|
||||
`CAL_SYNC1`. Full-screen "Calendars" editor reached from Settings.
|
||||
- ~~Per-calendar "manage in source app" deep-link~~ **shipped v2.2.0** — for
|
||||
synced calendars, open the app the calendar actually came from based on
|
||||
its `ACCOUNT_TYPE` (DAVx5 `bitfire.at.davdroid`, Google `com.google`,
|
||||
…); fall back to system account/sync settings. Plus an "add account"
|
||||
entry into system Accounts. Honest boundary for remote calendars.
|
||||
- **Remote calendar create/edit** *(go/no-go)* — creating a CalDAV
|
||||
collection (`MKCALENDAR`) or a Google calendar means an in-app sync
|
||||
client: **INTERNET permission, credential storage, the full server
|
||||
round-trip** — i.e. re-implementing DAVx5. DAVx5 exposes no public
|
||||
intent to delegate the create to it. Cosmetic local edits (color/name)
|
||||
to an existing synced row are possible but don't propagate to the server
|
||||
and may be overwritten on next sync — not promised. Same explicit
|
||||
go/no-go gate as the OSM/INTERNET item below.
|
||||
- Move event to another calendar (copy+delete model with a consequences
|
||||
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
|
||||
- ~~**Local-calendar backup / export** *(Tier 4 #10)*~~ **shipped v2.7.0** —
|
||||
device-only (`ACCOUNT_TYPE_LOCAL`) calendars had no sync and therefore no
|
||||
backup. Settings → Calendars → Export writes every event to a user-chosen
|
||||
`.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by
|
||||
UID). Closed the silent data-loss gap.
|
||||
|
||||
### Disable a calendar in-app *(captured 2026-06-25)*
|
||||
|
||||
A second, heavier visibility level **above** the existing per-view filter. Today
|
||||
the drawer's calendar filter (`hiddenCalendarIds` in `CalendarPrefs`) only hides
|
||||
a calendar's *events* from the month/week/day/agenda views — the calendar itself
|
||||
still clutters the drawer filter list, the event-form calendar picker, and the
|
||||
import target picker. "Disable" removes a calendar from the app's surfaces
|
||||
entirely; "hide" stays the lightweight, frequently-toggled control.
|
||||
|
||||
**Two-level model (both kept):**
|
||||
- **Hidden** (existing) — `hiddenCalendarIds`; a quick per-view checkbox in the
|
||||
drawer. Toggles events on/off in the views; the calendar stays listed
|
||||
everywhere. Operates only over the *enabled* calendars.
|
||||
- **Disabled** (new) — the calendar is gone from the app: not in the drawer
|
||||
filter list, not in the event-form picker, not in the import picker, and its
|
||||
events never appear (it's gated out of `instances()` like a hidden one). It
|
||||
remains visible **only** in Settings → Calendars, where the enable/disable
|
||||
toggle lives, so it can be brought back.
|
||||
|
||||
**Storage — app-side (DataStore), mirrors the hidden set.** Add
|
||||
`disabledCalendarIds: Set<Long>` + `setDisabledCalendarIds(...)` to
|
||||
`CalendarPrefs` (comma-separated string key, same shape as `hiddenCalendarIds`).
|
||||
**Does not touch** `CalendarContract.Calendars.VISIBLE` / `SYNC_EVENTS` — purely
|
||||
a Calendula-local preference, so other calendar apps are unaffected and the sync
|
||||
adapters stay out of it (privacy-clean, reversible).
|
||||
|
||||
**Where the disabled set is applied:**
|
||||
- `CalendarRepositoryImpl.instances()` + `searchEvents()` — exclude
|
||||
`calendarId ∈ (hidden ∪ disabled)` so a disabled calendar's events never show
|
||||
and aren't searchable. (`repository.calendars()` itself stays unfiltered/raw —
|
||||
the screens that need everything still get everything.)
|
||||
- `FilterViewModel.state` — drop disabled calendars from the drawer filter list
|
||||
(you can't hide/show what's disabled).
|
||||
- `EventEditViewModel.writableCalendars` — exclude disabled, so you can't create
|
||||
into a calendar you've removed from the app. Handle the last-used-calendar
|
||||
preselect falling on a now-disabled calendar (fall back to first enabled
|
||||
writable).
|
||||
- `ImportViewModel` — exclude disabled from the import target list.
|
||||
- `CalendarsScreen` / `CalendarsViewModel` — the **only** surface that lists
|
||||
disabled calendars; add `setDisabled(id, Boolean)` and a per-calendar toggle.
|
||||
|
||||
**UI — Settings → Calendars (no new tab).** Add an enable/disable control to each
|
||||
row on the existing `CalendarsScreen` (both the local and the synced/read-only
|
||||
groups — disabling is an app-side view choice, independent of write access).
|
||||
Disabled rows render visibly de-emphasised (dimmed) but keep the toggle so
|
||||
they're re-enableable. Follow the project dialog/list conventions (M3 grouped
|
||||
list, `option-card-modal-style-default` if a confirm/selection surface is needed).
|
||||
|
||||
**Decided behaviour:**
|
||||
- All calendars disabled → views show the existing empty state.
|
||||
- A disabled calendar that still holds events: events simply vanish from
|
||||
views/search until re-enabled (no data touched — it's a filter, not a delete).
|
||||
- Deep links / notifications pointing at an event in a disabled calendar still
|
||||
open its detail screen — detail is a direct id lookup, not an `instances()`
|
||||
query, so disabling never strands an existing link.
|
||||
|
||||
## Reminders — defaults & delivery reliability *(shipped v2.6.0; built on `feat/default-reminders`)*
|
||||
|
||||
Two themes bundled because both are "make reminders trustworthy" — the core of
|
||||
the "Calendula is your only calendar app" promise.
|
||||
|
||||
**Built in this slice (A + the safe half of B):** global timed default reminder
|
||||
+ a **separate all-day default** (day-scale lead times) + per-calendar override
|
||||
(timed events), applied on create with manual-edit / calendar-switch / all-day-
|
||||
toggle handling; three pickers + per-calendar override list in Settings →
|
||||
Notifications; battery-optimisation exemption row (status + system deep-link, no
|
||||
extra permission). `resolveDefaultReminder` + prefs round-trips unit-tested.
|
||||
Resolution model: all-day events use the all-day global default outright;
|
||||
per-calendar overrides govern timed events only. Reviewed (8-angle), fixes
|
||||
applied: form-reset state race, label-fn consolidation with the detail screen,
|
||||
inline wrapper + single combined flow read.
|
||||
|
||||
**Deliberately deferred (documented decisions, not oversights):**
|
||||
- *Absolute time-of-day for all-day reminders* — the all-day default is still
|
||||
minutes-before-midnight (day-scale presets), not "9am the day before" (open
|
||||
decision #2's richer half). Per-calendar all-day overrides also deferred.
|
||||
- *Self-scheduled alarms* — kept the existing provider-broadcast architecture
|
||||
(open decision #1). The battery exemption is the reliability lever; no
|
||||
`AlarmManager`/`USE_EXACT_ALARM` subsystem was added.
|
||||
- *Test-reminder diagnostic* and *battery prompt inside onboarding* — the
|
||||
exemption lives only in Settings for now (onboarding flow untouched to keep
|
||||
the change reviewable).
|
||||
|
||||
### A. Default reminders (global + per-calendar override)
|
||||
|
||||
**No provider backing.** `CalendarContract` has no column that auto-applies a
|
||||
default reminder per calendar — Google's per-calendar defaults live server-side.
|
||||
So both the global default *and* the per-calendar override are **app-side
|
||||
preferences**, applied by us at event-insert time. We inherit nothing from the
|
||||
synced calendar.
|
||||
|
||||
- **Storage (DataStore):**
|
||||
- `defaultReminderMinutes: Int?` — global default; `null` = "no reminder".
|
||||
- `defaultAllDayReminderMinutes: Int?` — separate all-day default (all-day
|
||||
reminders are expressed as minutes before midnight / day-before-at-time, not
|
||||
minutes before a start instant — they need their own value).
|
||||
- `perCalendarReminderOverride: Map<Long, Int?>` — keyed by calendar id;
|
||||
**absent key = inherit global**, explicit `null` = "no reminder for this
|
||||
calendar". (Same for an all-day override map if we want per-calendar all-day.)
|
||||
- **Apply on create:** a fresh event prefills its reminders list from
|
||||
override-or-global for the preselected calendar. Changing the calendar in the
|
||||
form re-applies the *new* calendar's default **only if the user hasn't manually
|
||||
edited the reminders** — track a dirty flag, mirroring the per-event-color
|
||||
reset pattern (v2.4).
|
||||
- **Edit semantics:** defaults apply to **new events only**; never rewrite
|
||||
reminders on existing events on open or on calendar-switch-during-edit.
|
||||
- **Settings UI (Notifications sub-page):**
|
||||
- Global default via OptionCard (None / at time of event / 5 / 10 / 15 / 30 min
|
||||
/ 1 h / 1 day / custom), plus the separate all-day default.
|
||||
- Per-calendar overrides: a row per writable calendar (in the Calendars screen
|
||||
or a Notifications subsection), each opening the same OptionCard with a
|
||||
leading **"Use global default"** option.
|
||||
|
||||
### B. Delivery reliability (exact alarms + battery)
|
||||
|
||||
The provider broadcasts `EVENT_REMINDER`, but on modern Android (Doze / OEM
|
||||
battery managers) delivery can be silently delayed or dropped. v1.4 deferred this;
|
||||
it directly undermines the feature's premise, so it rides in here.
|
||||
|
||||
- **Exact alarm — decision first:** trust the provider broadcast, or
|
||||
self-schedule via `AlarmManager.setExactAndAllowWhileIdle` for reliability?
|
||||
If we self-schedule, declare `USE_EXACT_ALARM` (API 33+, auto-granted for
|
||||
calendar/alarm-category apps, F-Droid-clean) with a `SCHEDULE_EXACT_ALARM`
|
||||
fallback for API 31–32 (user-revocable → settings deep-link prompt).
|
||||
- **Battery-optimization exemption:** a *soft, optional* prompt via
|
||||
`ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (settings deep-link — never the
|
||||
auto-grant intent), honest copy: "Android may delay reminders to save battery;
|
||||
exempt Calendula for on-time delivery." Shown once after the existing
|
||||
`POST_NOTIFICATIONS` onboarding step, reversible in Settings → Notifications.
|
||||
- **Diagnostics:** a "send a test reminder in 1 minute" button in Notifications
|
||||
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
|
||||
are notorious for suppressing it).
|
||||
|
||||
### Decisions made (as shipped in v2.6.0)
|
||||
|
||||
1. **Provider broadcast kept** — did not self-schedule via `AlarmManager`; the
|
||||
battery-optimisation exemption is the reliability lever (simplicity + battery
|
||||
cost won over the exact-alarm subsystem).
|
||||
2. **All-day reminders = minutes-before-midnight** (day-scale presets); absolute
|
||||
time-of-day ("9am the day before") deferred — see the deferred list above.
|
||||
3. **Per-calendar overrides live in the Notifications sub-page** (override list),
|
||||
governing timed events only.
|
||||
|
||||
### Round two
|
||||
|
||||
- ~~Snooze + dismiss actions on the notification~~ *(shipped in release/v2.8.0,
|
||||
Tier 4 #11)* — snooze re-fires a snooze-only exact alarm.
|
||||
|
||||
## Sharing & interop
|
||||
|
||||
- ~~Share event as .ics + open/receive .ics into a prefilled create form~~
|
||||
**shipped v2.7.0** — single-event share from detail; opening an `.ics` with one
|
||||
event prefills the create form, many events opens a bulk import (dedup by UID)
|
||||
- ~~ICS file import~~ **shipped v2.7.0** — covered by the open/receive `.ics`
|
||||
flow above (single → form, many → bulk import)
|
||||
|
||||
## Platform & launchers
|
||||
|
||||
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
|
||||
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0**
|
||||
- ~~"New event" Quick Settings tile~~ **shipped v2.8.0**
|
||||
|
||||
## Quality & reliability
|
||||
|
||||
- **Accessibility pass** — TalkBack content descriptions across all screens,
|
||||
dynamic-type / large-font reflow, touch-target audit. Quality bar for an
|
||||
F-Droid app; nothing tracks it yet.
|
||||
- **Reminder delivery reliability** — exact alarms + battery-optimization
|
||||
exemption; specced in the "Reminders — defaults & delivery reliability" slice
|
||||
above (Tier 4 #9).
|
||||
|
||||
## Locations & People *(go/no-go, captured 2026-06-11)*
|
||||
|
||||
Beyond classic calendar-client scope; discussed, deliberately not planned
|
||||
in detail yet:
|
||||
|
||||
- ~~**Contact address picker** for the location field via the system picker
|
||||
(`ACTION_PICK`)~~ **shipped v2.8.0** — one-shot, needs no READ_CONTACTS, fits
|
||||
the privacy story. The same no-permission mechanism was then reused for the
|
||||
attendee email picker (v2.8.0).
|
||||
- **OSM address autocomplete** in the location field (type "Brandenburger
|
||||
Tor" → tap suggestion → resolved address inserted). Backend would be
|
||||
Photon (Nominatim's public policy forbids autocomplete). **Requires the
|
||||
INTERNET permission** — first dent in the "no network access" promise;
|
||||
if built: opt-in (off by default), honest copy, configurable endpoint
|
||||
for self-hosters, onboarding footnote + F-Droid copy reworded. This
|
||||
trade-off is an explicit go/no-go decision before any work starts.
|
||||
- **Inline contact suggestions** while typing (needs READ_CONTACTS) — only
|
||||
if the picker proves clunky.
|
||||
- **Attendee editing** *(promoted out of this gated bucket 2026-06-22 — now
|
||||
Tier 5 #12, high-importance; the no-permission typed-email path is not an
|
||||
INTERNET/contacts call)*. See the "Attendee editing" sketch below.
|
||||
|
||||
### Attendee editing *(Tier 5 #12, opened 2026-06-22)*
|
||||
|
||||
The last read-only gap in the event model: attendees are read & shown on the
|
||||
detail screen (since v0.6) but the form can't write them. Make guests editable.
|
||||
|
||||
- **Read side already done:** `Attendee` domain model + status/relationship/type
|
||||
enums, `queryAttendees` + `EventDetailMapper.toAttendee` (with tests), and the
|
||||
attendees `DetailCard` + `AttendeeRow` in `EventDetailScreen`. Nothing to add
|
||||
there.
|
||||
- **Write side — SHIPPED (`feat/attendee-editing`, commit b0f34ff, 2026-06-22):**
|
||||
`attendees` on `EventForm`; a Guests section in `EventEditScreen` rendered as an
|
||||
inline grouped list — each guest a tonal card (avatar, name/email, tappable
|
||||
Required/Optional role chip, remove), the trailing card an inline email field
|
||||
(type → Done commits). Persisted by `reconcileAttendees` diffing the provider's
|
||||
`CalendarContract.Attendees` rows on insert + dirty-checked update (new guests =
|
||||
`RELATIONSHIP_ATTENDEE` / `STATUS_INVITED`; kept rows keep their status, only the
|
||||
required/optional type updates; organizer/resource/no-email rows untouched).
|
||||
Reminders restyled to the same grouped-list pattern in passing. Needs only the
|
||||
existing `WRITE_CALENDAR` — no new permission.
|
||||
- **Name on manual add — SHIPPED via a contact picker** (2026-06-22). The inline
|
||||
field is email-only (the quick path); the add row also has a **"from contacts"
|
||||
button**: `ACTION_PICK` on `ContactsContract.CommonDataKinds.Email.CONTENT_URI` →
|
||||
the system Contacts app returns a URI to the picked email row, queried for
|
||||
`Email.ADDRESS` **and** the contact `DISPLAY_NAME`, so a picked guest gets both
|
||||
email and name in one tap. The result Intent grants temporary read access, so
|
||||
**no `READ_CONTACTS` permission** — one-shot and user-driven, same no-permission
|
||||
mechanism as the location address picker above. Self/organizer rows stay
|
||||
non-editable.
|
||||
- **Invitation behavior — DECIDED 2026-06-22: record-only, all writable
|
||||
calendars.** Calendula has no INTERNET and never sends an invitation itself; it
|
||||
only writes `Attendees` rows. Whether a guest is notified is decided downstream:
|
||||
local calendars notify no one (no sync); CalDAV/DAVx5 PUTs the `ATTENDEE` lines
|
||||
and the *server* decides iMIP delivery; Google's sync adapter pushes the change
|
||||
and Google decides (third-party attendee writes are historically unreliable
|
||||
there). Editing is allowed on **any writable calendar** — not gated to local.
|
||||
- **Honest, backend-aware copy is mandatory** (this is the whole point of the
|
||||
decision): on a synced calendar show "Calendula doesn't send invitations —
|
||||
your calendar account may email guests when it syncs"; on a local calendar
|
||||
show "Stored on this device. No one is notified."
|
||||
- Calendula must **not fabricate an ORGANIZER** or otherwise fake scheduling
|
||||
state to coax a send — it writes the guest list faithfully and leaves
|
||||
scheduling entirely to the backend.
|
||||
- The optional "send an .ics invite via your email app" delegate (`ACTION_SEND`,
|
||||
still no INTERNET) was considered and **deferred** — revisit only if users ask
|
||||
to notify guests explicitly.
|
||||
- **Out of scope (for now):** RSVP/your-own-response editing, free/busy lookups,
|
||||
resource booking — all carry server round-trips or richer sync semantics.
|
||||
|
||||
## Consciously rejected
|
||||
|
||||
- Travel time / weather / smart suggestions (network, core-promise conflict)
|
||||
- Natural-language quick entry (high effort, locale-fragile; the prefilled
|
||||
form already covers fast entry)
|
||||
- Quick-add sheet (the prefilled full form already covers it — cut in v2.0)
|
||||
- Drag & drop rescheduling in day/week — **rejected** (owner decision,
|
||||
reaffirmed 2026-06-22): not wanted. Rescheduling stays via the edit form.
|
||||
@@ -1,177 +0,0 @@
|
||||
# Calendula — Current State
|
||||
|
||||
*Last updated: 2026-06-22*
|
||||
|
||||
## Status
|
||||
|
||||
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11.
|
||||
**Phase:** post-2.x theme-based backlog work (organised in `ROADMAP.md`).
|
||||
**Latest released tag: v2.7.5.** The whole Tier 4 (reliability/data-safety/
|
||||
interop) arc is now done or in flight:
|
||||
- v2.4.0 per-event colors (2026-06-17)
|
||||
- v2.5.0 jump-to-date, Agenda view, agenda + month home-screen widgets, "New
|
||||
event" launcher shortcut (2026-06-17)
|
||||
- v2.6.0 default reminders (global + per-calendar override, all-day default,
|
||||
battery-exemption row) + system per-app language (2026-06-18)
|
||||
- v2.7.0 **`.ics` engine** — single-event share, local-calendar backup export,
|
||||
open/receive `.ics` (single → form, many → bulk import, dedup by UID)
|
||||
(2026-06-18)
|
||||
- v2.7.1–v2.7.5 — crash-reporting + F-Droid reproducible-build hardening + fixes
|
||||
|
||||
**Next release `release/v2.8.0` (integration branch, not yet cut to main):**
|
||||
holds crash reports via the public Codeberg tracker (MR !27) + reminder
|
||||
snooze/dismiss notification actions (MR !28). Version bump to 2.8.0 happens at
|
||||
release-cut.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Design spec written and committed (`docs/superpowers/specs/2026-06-08-calendar-app-design.md`)
|
||||
- [x] V1 design decisions resolved (App name "Calendula", icon, seed color)
|
||||
- [x] Plan 01 written and executed — foundation lands (theme, icon, i18n, Hilt, DataStore, CI green)
|
||||
- [x] Plan 02 written and executed — data layer + permission flow + debug screen
|
||||
- [x] Month view (S1) — 6-week grid, event dots, today marker, swipe nav, three states (replaces debug screen)
|
||||
- [x] Week view (S2) — time schedule with overlap-resolved lanes, all-day strip, swipe nav, three states
|
||||
- [x] Day view (S3) — single-column slice reusing the week layout
|
||||
- [x] View-switcher (M1) wired — cycles Month ↔ Week ↔ Day
|
||||
- [x] Event-detail screen (S4) — full-screen, humanized recurrence
|
||||
- [x] Filter sheet (M3) — per-calendar visibility, grouped by account, persisted, applied centrally in the repository
|
||||
- [x] Settings (M4) — appearance (theme, dynamic colour, week start), language (per-app locales), about
|
||||
- [~] Jump-to-date (M2) — **cut from scope**; "Today" half shipped in v0.5, date-picker dropped
|
||||
- [x] Full event read (v0.6) — reminders, status, availability, access level,
|
||||
attendee role + self-response, foreign timezone, and linkified description
|
||||
URLs in the detail view; new domain enums + mapper unit tests. (A dedicated
|
||||
URL field was cut — no `CalendarContract` column backs it.)
|
||||
|
||||
- [x] v1.1 write foundation — `WRITE_CALENDAR` (onboarding asks READ+WRITE,
|
||||
only READ gates; contextual upgrade for v1.0 installs), read-only-calendar
|
||||
detection (`CALENDAR_ACCESS_LEVEL` → `canModifyContents`, actions hidden for
|
||||
WebCal/birthday calendars), delete from the detail screen (recurring:
|
||||
"only this event" via cancelled exception / "all events in the series"),
|
||||
repository + mapper tests
|
||||
|
||||
- [x] v1.2 create event — full-screen `EventEditScreen` (title, all-day,
|
||||
M3 date/time pickers with duration-preserving start moves, writable-only
|
||||
calendar picker preselecting the last-used calendar, location, description),
|
||||
"+" FAB on all three views prefilled with the visible day, `insertEvent`
|
||||
with provider-correct all-day normalisation (UTC midnights, exclusive end),
|
||||
domain/mapper/repository tests
|
||||
|
||||
- [x] v1.3 edit event (shipped 2026-06-11) — `EventEditScreen` reused for
|
||||
edit (detail-screen Edit action, `canModify`-gated, contextual WRITE
|
||||
upgrade), dirty-checked partial `update` on the Events row (recurring:
|
||||
series DTSTART moves by the user's delta, DURATION instead of DTEND),
|
||||
reminder diff by minutes (kept rows keep their method), simple recurrence
|
||||
picker (FREQ/INTERVAL/UNTIL/COUNT; complex RRULEs preserved verbatim and
|
||||
shown humanized), `EventFormField.Recurrence` incl. settings default,
|
||||
recurrence also available on create; domain/mapper/repository tests.
|
||||
Review round 1: weekly BYDAY day-toggles in the custom picker ("every week
|
||||
on Mon+Fri"). Review rounds 2–4: occurrence edit pulled forward from v2.0
|
||||
and made three-way like delete ("this" = exception row via
|
||||
`CONTENT_EXCEPTION_URI`, "this and following" = series split, "all" =
|
||||
series update); delete equally three-way (truncation via RRULE UNTIL);
|
||||
the edit-scope question moved to save time (Google model) — dirty
|
||||
recurring saves park in `SaveUiState.AwaitingScope`, a changed rule drops
|
||||
the "only this event" option
|
||||
|
||||
- [x] v1.4 reminder notifications (shipped 2026-06-11) — exported
|
||||
`EVENT_REMINDER` receiver → `CalendarAlerts` (SCHEDULED & due) →
|
||||
dedicated channel, tap opens detail (singleTop deep link); best-effort
|
||||
FIRED marking; one-time onboarding step requesting `POST_NOTIFICATIONS`
|
||||
with duplicate-reminders warning; Settings mirror. Provider only fires
|
||||
`METHOD_ALERT` rows (AOSP-verified), so email reminders never reach us
|
||||
|
||||
- [x] v2.0 conflict dialog + store polish (shipped 2026-06-11 as v2.0.0) —
|
||||
`EditSnapshot` compare on save (overwrite/discard; deleted → close),
|
||||
quick-add cut, calendar-switch → v3 backlog; F-Droid/README copy
|
||||
refreshed, fastlane screenshots DE+EN captured on-device
|
||||
|
||||
- [x] v2.1 (shipped 2026-06-15) — month grid shows real events as
|
||||
continuous multi-day bars; navigation-drawer View section
|
||||
(Month/Week/Day); cursor-jump fix in event text fields
|
||||
|
||||
- [x] v2.2 (shipped 2026-06-16) — tap an empty slot in day/week to create
|
||||
(prefilled with that day + tapped hour, snapped to the hour); local
|
||||
calendar management in a full-screen editor from Settings →
|
||||
Calendars: create/rename/recolor/delete device-only calendars
|
||||
(`ACCOUNT_TYPE_LOCAL`, sync-adapter insert) with name, pastel-previewed
|
||||
colour, and description (stored in `CAL_SYNC1`); synced calendars listed
|
||||
read-only grouped by account with a per-account "manage in source app"
|
||||
deep-link (resolved from the account's authenticator: DAVx5/ICSx5/…) and
|
||||
an add-account shortcut. Shared `InlineTextField` extracted to `ui.common`
|
||||
|
||||
- [x] v2.3 settings/calendars/drawer redesign (shipped 2026-06-16) — adopted a
|
||||
shared Material 3 grouped-list blueprint, modelled on the ReFra gallery app
|
||||
and extracted to `ui/common/GroupedList.kt` (`CollapsingScaffold` with a
|
||||
`LargeTopAppBar` exit-until-collapsed title; `GroupedRow` with Position-based
|
||||
corner grouping, press-animated corners, `selected` + `minHeight` knobs).
|
||||
- Settings: category hub (About card on top → version mark at the foot) with
|
||||
sliding sub-pages (Appearance / New event form / Notifications); token-
|
||||
based icon chips; theme/week-start/language pickers migrated from
|
||||
`DropdownMenu` to OptionCard dialogs. New `ic_gitea.xml` (Simple Icons,
|
||||
verbatim path) for the About "Source" button; en+de strings.
|
||||
- Calendar manager: same collapsing scaffold + grouped rows; shared
|
||||
`CalendarColorChip` (neutral chip, pastelised calendar glyph).
|
||||
- Navigation drawer: branded header, grouped View switcher (active view
|
||||
highlighted via `secondaryContainer`), the filter list restyled to
|
||||
grouped rows with a trailing checkbox; the whole drawer scrolls as one.
|
||||
- Cards use `surfaceContainerHigh` for readable contrast against `surface`.
|
||||
- Donate button on the About card deferred (target still TBD).
|
||||
|
||||
- [x] v2.4 per-event color (shipped 2026-06-17) — an optional "Color" field in
|
||||
the event form. Read/render already resolved `EVENT_COLOR` with a calendar
|
||||
fallback; this adds the write side and the picker. Palette-backed calendars
|
||||
(Google, some CalDAV) pick from the account's `Colors` (`TYPE_EVENT`) and
|
||||
write `EVENT_COLOR_KEY` so the color round-trips through sync; local
|
||||
calendars write a raw `EVENT_COLOR` from the shared `CALENDAR_COLOR_PALETTE`
|
||||
(extracted with the swatch row to `ui/common/ColorSwatchRow.kt`). Switching
|
||||
calendars resets the choice (a key is account-scoped). A settings toggle
|
||||
("Allow colors on unsupported calendars", off by default) extends the raw
|
||||
path to synced calendars with no palette, with an honest "may not survive
|
||||
sync" warning on the picker and in Settings. Color writes flow through
|
||||
insert / dirty-checked update / occurrence-exception; mapper + form tests.
|
||||
|
||||
- [x] v2.5 (shipped 2026-06-17) — Agenda view (4th top-level view),
|
||||
jump-to-date drawer date picker, two home-screen widgets (scrolling
|
||||
"Upcoming" agenda + month grid), and a "New event" launcher long-press
|
||||
shortcut
|
||||
|
||||
- [x] v2.6 (shipped 2026-06-18) — default reminders: global timed default +
|
||||
separate all-day default + per-calendar override (timed), applied on create
|
||||
with dirty-flag handling; three pickers + override list in Settings →
|
||||
Notifications; battery-optimisation exemption row (status + system deep-link,
|
||||
no new permission). Plus system per-app language (Android 13+) and an
|
||||
immediate-effect fix for the in-app language picker
|
||||
|
||||
- [x] v2.7 (shipped 2026-06-18) — the `.ics` engine: share a single event as
|
||||
`.ics` from the detail screen; back up local calendars (Settings → Calendars
|
||||
→ Export) to a SAF file; open/receive an `.ics` — one event prefills the
|
||||
create form, many events open a bulk import into a chosen calendar (dedup by
|
||||
UID, skip-and-report unrepresentable VTIMEZONE / RECURRENCE-ID / guests).
|
||||
Hand-rolled serializer/parser, zero deps. Plus all-day single-day UTC fix and
|
||||
a widget R8 keep-rule crash fix
|
||||
|
||||
- [x] v2.7.1–v2.7.5 (2026-06-21) — launch crash fix (listener before grant),
|
||||
user-controlled crash reporting, widget loading-spinner R8 keep rule, and
|
||||
F-Droid reproducible-build cleanups for the official repo
|
||||
|
||||
- [~] release/v2.8.0 (not yet cut) — crash reports via the public Codeberg
|
||||
tracker (MR !27) + reminder snooze/dismiss notification actions (MR !28,
|
||||
snooze self-schedules an exact alarm; primary delivery stays provider-broadcast)
|
||||
|
||||
## Next
|
||||
|
||||
1. Cut **v2.8.0** from `release/v2.8.0` (bump versionName → tag via the
|
||||
merge-driven pipeline) once on-device review signs off
|
||||
2. **Attendee editing** — the committed next feature (Tier 5 #12, high-
|
||||
importance, opened 2026-06-22). Attendees are already read & shown on the
|
||||
detail screen since v0.6; the gap is the write side — make guests editable
|
||||
in `EventEditScreen` / `EventForm` (add by typed email, role, remove),
|
||||
persisted by diffing `CalendarContract.Attendees`. No new permission for the
|
||||
typed-email path. **Invitation behavior DECIDED 2026-06-22: record-only on all
|
||||
writable calendars** — Calendula never sends (no INTERNET); honest backend-aware
|
||||
copy ("your account may email guests when it syncs" on synced calendars, "no one
|
||||
is notified" on local). Full sketch in `ROADMAP.md` → "Attendee editing".
|
||||
3. Then: the two INTERNET go/no-go calls (OSM autocomplete, remote calendar
|
||||
create/edit) and Tier 2/3 leftovers (quick-settings tile, now-line, week
|
||||
numbers in month, full-text search, accessibility pass). Drag-and-drop
|
||||
rescheduling is **rejected**.
|
||||
+1051
File diff suppressed because it is too large
Load Diff
+184
@@ -0,0 +1,184 @@
|
||||
# Contributing to Calendula
|
||||
|
||||
Calendula is a Material 3 Expressive calendar app that lives strictly on top of
|
||||
Android's `CalendarContract`, with no app database, sync stack or network access.
|
||||
That constraint shapes most review comments, so skim
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) before you write code. This file
|
||||
covers the practical side.
|
||||
|
||||
[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home for
|
||||
issues, pull requests and releases. The self-hosted Gitea instance referenced in the
|
||||
release docs is build infrastructure only; there is nothing to contribute there.
|
||||
Be decent to the people you meet in the tracker.
|
||||
|
||||
## Start with an issue
|
||||
|
||||
| You want to | Do this |
|
||||
|---|---|
|
||||
| Add a feature | **Open an issue first** and wait for a go-ahead |
|
||||
| Fix a bug | Open an issue, then a pull request |
|
||||
| Fix a typo, a comment, or docs | Just open the pull request |
|
||||
| Add or fix a translation | Use [Weblate](#translations), not a pull request |
|
||||
|
||||
Features get an opinion before they get code: whether Calendula should do a
|
||||
thing at all is the one decision a patch can't make. A feature PR that arrives
|
||||
without a discussed issue may be closed unmerged even when the code is good, so
|
||||
please don't spend a weekend on one first.
|
||||
|
||||
Bugs are more straightforward, but still start with an issue: it's what carries
|
||||
the milestone and gives the changelog something to link.
|
||||
|
||||
Issue templates cover bug, crash, feature and question. For a crash, let the app
|
||||
do the work: *Settings → Report a problem*, or the prompt shown after a crash,
|
||||
captures the stack trace and prefills the form. The report contains app, Android
|
||||
and device versions plus the trace, and no calendar content or personal data.
|
||||
|
||||
## Which branch to target
|
||||
|
||||
Calendula releases by merging a version bump into `main`, so `main` is a release
|
||||
trigger rather than a staging area. Work is assembled on release branches first.
|
||||
|
||||
Once your issue has a milestone, that milestone names your branch:
|
||||
|
||||
| Milestone | Target branch |
|
||||
|---|---|
|
||||
| `2.18.0` | `release/v2.18.0` |
|
||||
|
||||
Every milestone has a matching branch. If it's somehow missing, target `main` and
|
||||
mention it in the PR, and it will be retargeted. Don't pick an older release branch:
|
||||
they're kept after shipping, so the newest one isn't necessarily yours.
|
||||
|
||||
## Translations
|
||||
|
||||
**Never edit a `values-*/strings.xml` file in a pull request**, German included.
|
||||
Translations are owned by a self-hosted Weblate that writes to this repository
|
||||
directly, and a hand-edit is overwritten on the next sync.
|
||||
|
||||
[Translate Calendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)
|
||||
|
||||
Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
|
||||
picks it up and offers it to translators. Partial translations are expected and
|
||||
fine; missing keys are informational. Stale and orphaned keys are not, so run
|
||||
|
||||
```sh
|
||||
python3 scripts/check_translations.py
|
||||
```
|
||||
|
||||
before pushing. It reports those more clearly than lint's `MissingTranslation`
|
||||
does.
|
||||
|
||||
## Build and test
|
||||
|
||||
```sh
|
||||
git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git
|
||||
```
|
||||
|
||||
The `floret-kit` submodule is a composite build compiled from source. An
|
||||
existing clone needs `git submodule update --init --recursive`, or nothing
|
||||
resolves.
|
||||
|
||||
- **JDK 17**, not newer: the Android Gradle Plugin requires exactly 17. Set
|
||||
`JAVA_HOME` if your default differs.
|
||||
- **Android SDK**: platform 37 (`compileSdk`) and build-tools 36.0.0, located
|
||||
via `ANDROID_HOME` or a gitignored `local.properties` with `sdk.dir`. If you
|
||||
go the `local.properties` route, the included build needs its own copy at
|
||||
`floret-kit/local.properties`; `ANDROID_HOME` covers both at once and is the
|
||||
easier path.
|
||||
|
||||
The Gradle wrapper is checked in, so no system Gradle is needed.
|
||||
|
||||
```sh
|
||||
./gradlew lint test assembleDebug # roughly what CI runs
|
||||
```
|
||||
|
||||
A single test class, or a pattern:
|
||||
|
||||
```sh
|
||||
./gradlew testDebugUnitTest --tests "de.jeanlucmakiola.calendula.domain.SimpleRecurrenceTest"
|
||||
./gradlew testDebugUnitTest --tests "*SimpleRecurrence*"
|
||||
```
|
||||
|
||||
CI reports one `CI` check per pull request: `lintDebug`, `testDebugUnitTest`,
|
||||
`assembleDebug`, and a Trivy scan. Pull requests touching only docs, F-Droid
|
||||
metadata or the licence skip the Android build and go green quickly. More detail
|
||||
in [`docs/BUILDING.md`](docs/BUILDING.md).
|
||||
|
||||
## The rules
|
||||
|
||||
These are the ones that turn into review comments.
|
||||
|
||||
1. **No network.** Calendula holds no `INTERNET` permission, and that's a
|
||||
feature rather than an oversight. Anything that would need one is a product
|
||||
decision before it's a patch. The crash reporter opens a prefilled web issue
|
||||
on purpose instead of posting anything itself.
|
||||
2. **The provider is the only database.** No Room, no cache, no local mirror of
|
||||
events. `CalendarContract` is the single source of truth, which is also why
|
||||
externally synced changes work for free.
|
||||
3. **Don't patch UI state after a write.** A `ContentObserver` re-queries and
|
||||
views recompose from fresh provider state. Hand-patching a list after saving
|
||||
appears to work, then drifts away from what the provider stored.
|
||||
4. **`domain/` has no Android imports.** Models, validation, recurrence
|
||||
rendering, conflict snapshots and the `.ics` codec stay pure Kotlin so they
|
||||
remain JVM-testable.
|
||||
5. **Tests run on the JVM.** JUnit 5 + Truth + Turbine. The seams exist for you:
|
||||
fake the data source (`FakeCalendarDataSource`), and feed mappers plain maps
|
||||
through `ColumnReader` instead of cursors. Instrumented tests are a last
|
||||
resort.
|
||||
6. **Read before touching the subtle pipelines.** Recurring writes (UNTIL vs
|
||||
DURATION, exception URIs, series splits), save-conflict detection and reminder
|
||||
delivery (post-before-mark) follow provider-driven rules that are documented
|
||||
in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) and are not guessable from
|
||||
the code alone.
|
||||
7. **Don't break reproducible builds.** `vcsInfo`, `dependenciesInfo` and the AGP
|
||||
metadata block are disabled on purpose so the official F-Droid repo can verify
|
||||
our binary against a from-source rebuild.
|
||||
`scripts/check_reproducible_release.sh` runs on every pull request, including
|
||||
docs-only ones.
|
||||
|
||||
## UI conventions
|
||||
|
||||
Material 3 Expressive throughout, built from the system's own tokens and
|
||||
components: colour-scheme tokens rather than hardcoded colours, `ListItem` for
|
||||
settings rows.
|
||||
|
||||
**Selection pickers are full-screen.** Every browse-style "choose one" surface
|
||||
uses floret-kit's `FullScreenPicker` / `OptionPicker`; one that needs a commit or
|
||||
extra action passes it through the picker's `actions` slot. The exception is the
|
||||
recurring-scope chooser (*this / this and following / all*), which stays a
|
||||
compact dialog, because a two- or three-option decision reads better as a popup than as
|
||||
a nearly empty screen. `AlertDialog` is for plain confirmations only, and radio-
|
||||
or text-list dialogs aren't used at all.
|
||||
|
||||
Shared UI machinery lives in the `floret-kit` submodule and has
|
||||
[its own contributing guide](https://codeberg.org/jlmakiola/floret-kit/src/branch/main/CONTRIBUTING.md);
|
||||
changing it means a pull request against that repository plus a submodule bump
|
||||
here.
|
||||
|
||||
## Commits and pull requests
|
||||
|
||||
Conventional commits, scoped to the area you touched:
|
||||
|
||||
```
|
||||
fix(calendars): keep an event's own calendar when it is switched off
|
||||
feat(month): pull-to-expand the split view (#38)
|
||||
docs(architecture): record what the second review pass changed
|
||||
```
|
||||
|
||||
Types in use: `feat` `fix` `docs` `refactor` `style` `chore` `ci` `build`
|
||||
`revert`. Reference the issue in the subject or the body. Keep commits small.
|
||||
Small commits revert cleanly, which matters more here than a tidy history.
|
||||
|
||||
If your change is user-visible, add an entry under `## [Unreleased]` in
|
||||
[`CHANGELOG.md`](CHANGELOG.md). Match the surrounding voice: entries describe
|
||||
what changed *for the person using the app*, and why. Code changes don't go
|
||||
there. Link the issue and add its reference at the bottom of the file. It may get
|
||||
reworded when the release is cut, so don't agonise over it.
|
||||
|
||||
Please don't commit planning or design documents. Code, tests, architecture notes
|
||||
and the changelog land; the reasoning belongs in the commit message and the
|
||||
issue.
|
||||
|
||||
## Licence
|
||||
|
||||
Calendula is [MIT](LICENSE). By contributing you agree your changes ship under
|
||||
the same licence.
|
||||
@@ -0,0 +1,10 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
# fastlane is used ONLY to upload the release bundle to Google Play
|
||||
# (see fastlane/Fastfile). It is not part of the build or the signing path, so
|
||||
# it never runs on a PR — only in release.yaml's `play` job.
|
||||
#
|
||||
# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is
|
||||
# committed on purpose: this resolves an uploader's transitive deps, not the
|
||||
# app's, and none of it affects the reproducible release build.
|
||||
gem "fastlane", "2.237.0"
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
<h1>Calendula</h1>
|
||||
|
||||
<p><strong>A modern Material 3 Expressive calendar for Android.</strong><br>
|
||||
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
|
||||
<p><strong>A Material 3 Expressive calendar for Android.</strong><br>
|
||||
It works on top of the system calendar and has no network access.</p>
|
||||
|
||||
<p>
|
||||
<a href="https://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">
|
||||
@@ -18,74 +18,98 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
|
||||
<p>
|
||||
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="56"></a>
|
||||
|
||||
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
|
||||
|
||||
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="19%" alt="Week view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="19%" alt="Month view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-edit.png" width="19%" alt="Event form">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="13%" alt="Week view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="13%" alt="Month view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-agenda.png" width="13%" alt="Agenda view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="13%" alt="Event detail">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-recurring.png" width="13%" alt="Editing a recurring event">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-calendars.png" width="13%" alt="Calendars from your accounts">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/07-dark.png" width="13%" alt="Dark theme">
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
Calendula is named after the flower whose name — like the word *calendar* —
|
||||
comes from the Latin *kalendae*, the first day of the month. It lives
|
||||
entirely on top of Android's `CalendarContract`: any calendar synced to your
|
||||
device (CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply
|
||||
appears, and everything you create or edit syncs back the same way. No own
|
||||
database, no sync stack reinvented.
|
||||
Calendula is named after the flower. Both its name and the word *calendar*
|
||||
come from the Latin *kalendae*, the first day of the month.
|
||||
|
||||
## ✨ Features
|
||||
The app works directly on Android's `CalendarContract`. Any calendar synced to
|
||||
your device (CalDAV via DAVx5, Google, local, WebCal subscriptions) shows up in
|
||||
Calendula, and whatever you create or edit syncs back the same way. Calendula
|
||||
has no database or sync code of its own.
|
||||
|
||||
**Calendar**
|
||||
## Features
|
||||
|
||||
- Month, week, and day views with a one-tap view switcher
|
||||
- Full event details — attendees and their responses, reminders, recurrence
|
||||
(humanized), availability, visibility, foreign time zones
|
||||
- Per-calendar visibility toggle, grouped by account
|
||||
### Calendar
|
||||
|
||||
**Editing**
|
||||
- Month, week and day views, with a switcher in the top bar
|
||||
- Event details with attendees and their responses, reminders, recurrence in
|
||||
plain words, availability, visibility and foreign time zones
|
||||
- Calendars can be shown or hidden one by one, grouped by account
|
||||
- Your contacts' birthdays, anniversaries and other dates, if you turn it on.
|
||||
Contacts are read on the device and never changed.
|
||||
|
||||
- Create, edit, and delete events — including recurring events with scoped
|
||||
writes: *only this event*, *this and all following*, or *the whole series*
|
||||
- Recurrence picker with one-tap presets and custom rules (interval, weekday
|
||||
toggles, end conditions); rules it can't express are preserved verbatim
|
||||
- Conflict-safe saves: if an event changed elsewhere while you were editing,
|
||||
Calendula asks instead of silently overwriting
|
||||
- Read-only calendars (WebCal, birthdays) are detected and respected
|
||||
### Editing
|
||||
|
||||
**Reminders**
|
||||
- Create, edit and delete events. For a recurring event you choose whether a
|
||||
change applies to *only this event*, *this and all following*, or *the whole
|
||||
series*.
|
||||
- The repeat picker has presets and custom rules (interval, weekdays, end
|
||||
condition). Rules it can't show are kept as they are.
|
||||
- If an event changed elsewhere while you were editing it, Calendula asks
|
||||
before saving over it.
|
||||
- Read-only calendars such as WebCal subscriptions and birthdays are detected
|
||||
and can't be edited by accident.
|
||||
|
||||
- Event reminders delivered by Calendula itself as notifications —
|
||||
essential when it's your only calendar app, since Android delegates
|
||||
reminder delivery to calendar apps
|
||||
- Tap a reminder to land on the event
|
||||
### Reminders
|
||||
|
||||
**Design & privacy**
|
||||
- Calendula shows event reminders as notifications itself. Android leaves
|
||||
reminder delivery to calendar apps, so if Calendula is your only one, this is
|
||||
where your reminders come from.
|
||||
- Tapping a reminder opens the event.
|
||||
|
||||
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
|
||||
expressive motion and shapes, light/dark theme
|
||||
- German and English UI, per-app language setting — and [open to community
|
||||
translations](#-translations)
|
||||
- **Zero telemetry, zero analytics, no internet permission** — your data
|
||||
never leaves the device
|
||||
### Design and privacy
|
||||
|
||||
## 📦 Install
|
||||
- Material 3 Expressive throughout, with dynamic color on Android 12+, light
|
||||
and dark themes, and expressive motion and shapes
|
||||
- Available in 16 languages, with a per-app language setting.
|
||||
[Help translate](#translations).
|
||||
- No telemetry, no analytics and no internet permission. Your data stays on
|
||||
the device.
|
||||
|
||||
## Install
|
||||
|
||||
All channels install the same app:
|
||||
|
||||
| Channel | Updates | Notes |
|
||||
| --- | --- | --- |
|
||||
| [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup |
|
||||
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; add the repo once |
|
||||
| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automatic with Obtainium |
|
||||
| [Google Play](#google-play) | Per release | Signed with Google's key, see below |
|
||||
| [Build from source](#build-from-source) | Whenever you build | Full control |
|
||||
|
||||
### F-Droid (recommended)
|
||||
|
||||
Calendula is on the **official [F-Droid](https://f-droid.org) repository** —
|
||||
just search for **Calendula** in any F-Droid client, or
|
||||
Calendula is in the official [F-Droid](https://f-droid.org) repository. Search
|
||||
for Calendula in any F-Droid client, or
|
||||
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
|
||||
|
||||
### Self-hosted repo (latest builds)
|
||||
F-Droid builds from source on its own schedule, so a new version usually shows
|
||||
up there a few days after release.
|
||||
|
||||
New versions are built, signed, and published to a self-hosted repository the
|
||||
moment each tag lands — usually a few days ahead of the official repo, which
|
||||
rebuilds on F-Droid's own schedule. Add it for the freshest builds:
|
||||
### Self-hosted F-Droid repo (fastest updates)
|
||||
|
||||
The release pipeline builds, signs and publishes every release to a
|
||||
self-hosted F-Droid repository, so new versions land there first. Add it once
|
||||
and your F-Droid client handles updates from then on:
|
||||
|
||||
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
|
||||
link below on your phone):
|
||||
@@ -98,28 +122,86 @@ rebuilds on F-Droid's own schedule. Add it for the freshest builds:
|
||||
fingerprint (SHA-256):
|
||||
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
|
||||
|
||||
2. Refresh, search for **Calendula**, install.
|
||||
2. Refresh, search for Calendula and install it.
|
||||
|
||||
Both channels share the same signing key, so you can switch between them
|
||||
without reinstalling. Or build from source — see below.
|
||||
### Codeberg release / Obtainium
|
||||
|
||||
## 📚 Documentation
|
||||
Every release is also on
|
||||
[Codeberg](https://codeberg.org/jlmakiola/calendula/releases) with the signed
|
||||
APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum. Download it and install
|
||||
it directly.
|
||||
|
||||
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
|
||||
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
||||
- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next
|
||||
For automatic updates from Codeberg, use
|
||||
[Obtainium](https://github.com/ImranR98/Obtainium). On your phone,
|
||||
[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula),
|
||||
or add it by hand: *Add App*, paste `https://codeberg.org/jlmakiola/calendula`,
|
||||
then *Add*. Obtainium then watches the releases and tells you when a new one
|
||||
is out.
|
||||
|
||||
## 🌍 Translations
|
||||
### Google Play
|
||||
|
||||
Calendula ships in German and English, and you're warmly invited to add your
|
||||
language. Translations are managed on a self-hosted **Weblate**:
|
||||
Calendula is on
|
||||
[Google Play](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula).
|
||||
|
||||
**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
|
||||
Play builds are signed with Google's key rather than mine, so switching between
|
||||
Play and any other channel means uninstalling first. You lose the app settings.
|
||||
Your events live in the system calendar and aren't affected.
|
||||
|
||||
No coding needed — register on the Weblate server, pick (or request) a language,
|
||||
and translate the strings in your browser. You can also reach this link in the
|
||||
app from the top of **Settings → App language**.
|
||||
### Build from source
|
||||
|
||||
## 📜 License
|
||||
It's a plain Gradle build with no proprietary dependencies. See
|
||||
[docs/BUILDING.md](docs/BUILDING.md), and note the `floret-kit` submodule.
|
||||
|
||||
[MIT](LICENSE) — Jean-Luc Makiola, 2026
|
||||
<sub>Official F-Droid, the self-hosted repo and the Codeberg releases share the
|
||||
same signing key, so you can switch between them without reinstalling. Google
|
||||
Play is the exception (see above).</sub>
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Contributing](CONTRIBUTING.md): how to report bugs, propose features and send patches
|
||||
- [Building from source](docs/BUILDING.md): requirements and Gradle tasks
|
||||
- [Architecture](docs/ARCHITECTURE.md): the layers and the main pipelines
|
||||
- [Milestones](https://codeberg.org/jlmakiola/calendula/milestones): what has shipped and what's next
|
||||
- [Privacy policy](docs/PRIVACY.md): the source of the page at
|
||||
[jeanlucmakiola.de/calendula/privacy](https://jeanlucmakiola.de/calendula/privacy)
|
||||
|
||||
## Contributing
|
||||
|
||||
Bug reports, ideas and patches are welcome on
|
||||
[Codeberg](https://codeberg.org/jlmakiola/calendula/issues).
|
||||
|
||||
Start with an issue. A feature gets a yes or no before anyone writes code, and
|
||||
features and bugs are assigned a milestone whose `release/vX.Y.Z` branch your
|
||||
pull request targets. Typo and docs fixes can go straight to a pull request.
|
||||
Translations go through [Weblate](#translations), not pull requests.
|
||||
|
||||
Read [CONTRIBUTING.md](CONTRIBUTING.md) before writing code. It covers the
|
||||
workflow, the build (including the `floret-kit` submodule) and the
|
||||
architectural rules changes are reviewed against.
|
||||
|
||||
## Translations
|
||||
|
||||
Calendula is written in English. Translators on Weblate have added Arabic,
|
||||
Belarusian, Chinese (Simplified), Czech, French, German, Hungarian, Italian,
|
||||
Japanese, Polish, Portuguese (Portugal and Brazil), Russian, Slovak and Spanish,
|
||||
some of them partly. Untranslated strings fall back to English, so a partial
|
||||
translation is fine. Translations are managed on a self-hosted Weblate:
|
||||
|
||||
[Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)
|
||||
|
||||
You don't need to code. Register on the Weblate server, pick a language (or
|
||||
request one) and translate in your browser. The app links here from the top of
|
||||
*Settings → App language*.
|
||||
|
||||
## Privacy
|
||||
|
||||
Calendula has no analytics, tracking, ads, crash-reporting SDK or third-party
|
||||
services. It doesn't have the internet permission, so it can't send anything
|
||||
anywhere. Your events stay in Android's own calendar store.
|
||||
|
||||
[Privacy policy](https://jeanlucmakiola.de/calendula/privacy), written in
|
||||
[`docs/PRIVACY.md`](docs/PRIVACY.md), which is what that page renders.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE), Jean-Luc Makiola, 2026
|
||||
|
||||
+19
-6
@@ -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 = 21200
|
||||
versionName = "2.12.0"
|
||||
versionCode = 22200
|
||||
versionName = "2.22.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -113,10 +113,17 @@ 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/
|
||||
// check_translations.py guards the same invariants with clearer,
|
||||
// translator-facing messages.
|
||||
informational += "MissingTranslation"
|
||||
// it. A translated <plurals> may likewise skip a CLDR quantity form its
|
||||
// locale defines (e.g. Arabic "zero"): Android falls back to "other" for
|
||||
// any form it cannot find, so MissingQuantity is informational too.
|
||||
// What a translation must NOT skip is "other" itself — nothing falls back
|
||||
// for that one, not even the base locale, so it throws
|
||||
// Resources$NotFoundException at runtime (Codeberg #297/#298).
|
||||
// MissingQuantity doesn't tell the two cases apart, so that invariant is
|
||||
// enforced by scripts/check_translations.py instead, with a clearer
|
||||
// translator-facing message. Stale/extra keys (ExtraTranslation) stay
|
||||
// fatal.
|
||||
informational += listOf("MissingTranslation", "MissingQuantity")
|
||||
}
|
||||
|
||||
testOptions {
|
||||
@@ -161,6 +168,12 @@ dependencies {
|
||||
implementation(libs.androidx.glance.material3)
|
||||
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation("de.jeanlucmakiola.floret:core-time")
|
||||
implementation("de.jeanlucmakiola.floret:core-locale")
|
||||
implementation("de.jeanlucmakiola.floret:core-crash")
|
||||
implementation("de.jeanlucmakiola.floret:core-reminders")
|
||||
implementation("de.jeanlucmakiola.floret:identity")
|
||||
implementation("de.jeanlucmakiola.floret:components")
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
|
||||
Vendored
+30
@@ -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,21 @@
|
||||
# SessionWorker never ran, and widgets were stuck on their loading layout
|
||||
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
|
||||
-keep class * extends androidx.work.InputMerger { <init>(...); }
|
||||
|
||||
# Glance identifies a widget by its GlanceAppWidget subclass's *canonical name*:
|
||||
# GlanceAppWidgetManager persists a providerName -> receivers map under that
|
||||
# string, and `updateAll` looks the widget's app-widget ids up through it. Under
|
||||
# R8 full mode (AGP 9 default) MonthWidget and AgendaWidget — same supertype,
|
||||
# same overrides, no distinguishing members — were horizontally merged into one
|
||||
# class, so both receivers registered under the *same* provider name and
|
||||
# `AgendaWidget().updateAll()` resolved the month widget's id too, redrawing a
|
||||
# placed month widget as the agenda one on the next data change (#89). Keeping
|
||||
# the real names also survives app updates, which would otherwise renumber the
|
||||
# obfuscated name and orphan the stored mapping.
|
||||
-keep class * extends androidx.glance.appwidget.GlanceAppWidget
|
||||
|
||||
# Belt and braces one level up: the two receivers are nearly as alike, and the
|
||||
# provider map is keyed off the receiver component too. Redundant today (AGP's
|
||||
# manifest-derived rules cover them), but #89 cost a release to diagnose and the
|
||||
# guarantee should not rest on a component staying in the manifest.
|
||||
-keep class * extends androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
@@ -33,6 +33,14 @@
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
|
||||
<!--
|
||||
A reboot clears every pending alarm, including the one holding the next
|
||||
reminder. Now that the app schedules that alarm itself (#75) rather than
|
||||
leaning on the provider's, it has to hear about the reboot to re-arm it —
|
||||
otherwise reminders simply stop after a restart.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||
returns null and the calendar manager's per-account "manage" button can't
|
||||
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
||||
@@ -63,10 +71,9 @@
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- The MAIN/LAUNCHER entry (the launcher icon + its label) lives on
|
||||
the two <activity-alias> below, so the app name can be switched at
|
||||
runtime (issue #44). MainActivity keeps every other filter. -->
|
||||
|
||||
<!-- Be selectable as the system calendar app. Android has no API for
|
||||
an app to make itself the default, so registering the filters
|
||||
@@ -97,26 +104,156 @@
|
||||
<data android:mimeType="time/epoch" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||
<!-- Open a .ics/.vcs file (file manager / email attachment / browser).
|
||||
The three MIME types cover the common labels the same calendar
|
||||
data arrives under: iCalendar 2.0 (text/calendar), the older
|
||||
vCalendar 1.0 / .vcs (text/x-vcalendar), and application/ics some
|
||||
mail apps emit — Android cross-products the scheme and mimeType
|
||||
tags, so each MIME is accepted on both schemes (matches Etar). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="content" android:mimeType="text/calendar" />
|
||||
<data android:scheme="file" android:mimeType="text/calendar" />
|
||||
<data android:scheme="content" />
|
||||
<data android:scheme="file" />
|
||||
<data android:mimeType="text/calendar" />
|
||||
<data android:mimeType="text/x-vcalendar" />
|
||||
<data android:mimeType="application/ics" />
|
||||
</intent-filter>
|
||||
<!-- Receive a .ics shared from another app. -->
|
||||
<!-- Same .ics/.vcs data arriving mislabelled as a generic download —
|
||||
application/octet-stream — the way many mail clients, browsers and
|
||||
file managers hand off attachments. Matched by file extension, so
|
||||
this stays a separate filter: a pathPattern here must not narrow
|
||||
the MIME-typed VIEW filter above (that one has no path and must
|
||||
keep matching regardless of name). The import handler ignores the
|
||||
MIME type, so a let-through octet-stream .ics imports normally.
|
||||
Best-effort: pathPattern is reliable for file:// (and content://
|
||||
whose path carries the filename); content:// URIs that expose no
|
||||
name still fall back to the MIME-typed filter above. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="content" />
|
||||
<data android:scheme="file" />
|
||||
<data android:host="*" />
|
||||
<data android:mimeType="application/octet-stream" />
|
||||
<data android:pathPattern=".*\\.ics" />
|
||||
<data android:pathPattern=".*\\.vcs" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Receive a .ics/.vcs shared from another app (same MIME set). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/calendar" />
|
||||
<data android:mimeType="text/x-vcalendar" />
|
||||
<data android:mimeType="application/ics" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- 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
|
||||
@@ -141,17 +278,22 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
||||
no notification itself — a calendar app must (v1.4, Etar model).
|
||||
Exported: the broadcast arrives from the provider's process. -->
|
||||
<!-- Reminder delivery is the app's own (#75): it plans the alarms from
|
||||
Instances + Reminders instead of waiting for the provider's
|
||||
EVENT_REMINDER broadcast, which OEM-modified providers demonstrably
|
||||
retarget or never send. This receiver takes our scan alarm plus
|
||||
every outside event that invalidates it — boot and package-replace
|
||||
wipe pending alarms, and a clock or timezone change moves every
|
||||
reminder relative to the one that is armed.
|
||||
Exported: the system broadcasts arrive from outside the app. -->
|
||||
<receiver
|
||||
android:name=".data.reminders.EventReminderReceiver"
|
||||
android:name=".data.reminders.ReminderScheduleReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.EVENT_REMINDER" />
|
||||
<data
|
||||
android:host="com.android.calendar"
|
||||
android:scheme="content" />
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
<action android:name="android.intent.action.TIME_SET" />
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
@@ -188,9 +330,13 @@
|
||||
</receiver>
|
||||
|
||||
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
||||
PROVIDER_CHANGED on any data change (our writes and external sync),
|
||||
and the system broadcasts the date/time ones at midnight / clock
|
||||
changes so "today" highlighting rolls over. -->
|
||||
PROVIDER_CHANGED on any data change (our writes and external sync).
|
||||
The day boundary arrives as the app's own ROLLOVER alarm (#228), by
|
||||
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is
|
||||
a free extra only, since Android 8+ withholds it from manifest
|
||||
receivers. The four below re-arm that alarm: TIME_SET /
|
||||
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it.
|
||||
Exported: the system broadcasts arrive from outside the app. -->
|
||||
<receiver
|
||||
android:name=".widget.WidgetUpdateReceiver"
|
||||
android:exported="true">
|
||||
@@ -204,6 +350,8 @@
|
||||
<action android:name="android.intent.action.DATE_CHANGED" />
|
||||
<action android:name="android.intent.action.TIME_SET" />
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
|
||||
@@ -4,10 +4,15 @@ import android.app.Application
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -24,10 +29,66 @@ 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.
|
||||
// The prefilled issue's own title and body are the kit's, in English:
|
||||
// they are posted to the tracker, so they don't follow the device
|
||||
// language the way the dialog around them does.
|
||||
CrashReporter.install(
|
||||
this,
|
||||
CrashConfig(
|
||||
appLabel = getString(R.string.app_name),
|
||||
newIssueUrl = getString(R.string.report_issue_url),
|
||||
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
||||
),
|
||||
)
|
||||
reconcileAutoBackup()
|
||||
reconcileSpecialDates()
|
||||
reconcileCalendarVisibility()
|
||||
startReminderDelivery()
|
||||
reconcileWidgetRollover()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-arm the widgets' midnight rollover from whatever is actually placed
|
||||
* (#228). Idempotent, and it covers what no broadcast reaches — an alarm
|
||||
* dropped by a force-stop is armed again the next time the app is opened.
|
||||
* Off the main thread: a handful of binder calls on every process start.
|
||||
*/
|
||||
private fun reconcileWidgetRollover() {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
WidgetRolloverScheduler.sync(this@CalendulaApp)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring reminder delivery up with the process (#75): a scan re-arms whatever
|
||||
* the system dropped and posts what a missed alarm still owes, then the
|
||||
* provider watch keeps edits re-planned. The daily worker is the backstop.
|
||||
*/
|
||||
private fun startReminderDelivery() {
|
||||
val deps = EntryPointAccessors.fromApplication(
|
||||
this, ReminderMaintenanceWorker.Deps::class.java,
|
||||
)
|
||||
val scanner = deps.reminderScanner()
|
||||
scanner.startWatchingProvider()
|
||||
scanner.scanInBackground()
|
||||
ReminderMaintenanceScheduler.apply(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any calendar switch-off not yet written to `Calendars.VISIBLE`,
|
||||
* including the set inherited from the retired app-local model (#75). A
|
||||
* no-op in the steady state; `RootScreen` re-runs it after a later grant.
|
||||
*/
|
||||
private fun reconcileCalendarVisibility() {
|
||||
val deps = EntryPointAccessors.fromApplication(
|
||||
this, CalendarVisibilityReconciler.Deps::class.java,
|
||||
)
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
deps.calendarVisibilityReconciler().run()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,10 +111,8 @@ class CalendulaApp : Application() {
|
||||
|
||||
/**
|
||||
* Re-arm (or cancel) the daily special-dates reconcile from the saved
|
||||
* settings on every launch — like [reconcileAutoBackup], this cancels
|
||||
* orphaned work after the feature is turned off and re-schedules after a
|
||||
* reinstall. The foreground trigger (RootScreen ON_RESUME) does the on-open
|
||||
* refresh, so no immediate run is needed here.
|
||||
* settings, like [reconcileAutoBackup]. The on-open refresh is RootScreen's
|
||||
* ON_RESUME trigger, so no immediate run is needed here.
|
||||
*/
|
||||
private fun reconcileSpecialDates() {
|
||||
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)
|
||||
|
||||
@@ -2,8 +2,11 @@ package de.jeanlucmakiola.calendula
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -11,6 +14,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -22,28 +26,48 @@ 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.LocalShowHourGrid
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.DebugRibbon
|
||||
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.
|
||||
@@ -57,6 +81,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.
|
||||
@@ -77,10 +111,29 @@ class MainActivity : AppCompatActivity() {
|
||||
return
|
||||
}
|
||||
|
||||
enableEdgeToEdge()
|
||||
requestedDetailKey = intent.detailKeyOrNull()
|
||||
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||
Configuration.UI_MODE_NIGHT_YES
|
||||
applyEdgeToEdge()
|
||||
// Android hands the intent that started the task back to onCreate every
|
||||
// time the activity is recreated — on a rotation, or on a restart after
|
||||
// the system reclaimed the process. Re-running a launch that opens one
|
||||
// specific event is what made a single reminder or widget tap re-open
|
||||
// that event on every later launch, an error screen once the event had
|
||||
// been deleted (#309); the user left it long ago, and the activity
|
||||
// restores where they actually were along with its saved state.
|
||||
//
|
||||
// Only those channels are held back. An .ics import or a prefilled
|
||||
// create form keeps no saved state of its own, so re-reading the intent
|
||||
// is what carries one through a recreation.
|
||||
val replaying = savedInstanceState != null || intent.isRelaunch()
|
||||
if (!replaying) {
|
||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||
requestedEditKey = intent.editEventKeyOrNull()
|
||||
}
|
||||
requestedNav = intent.navRequestOrNull()
|
||||
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
|
||||
requestedImportUri = intent.importUriOrNull()
|
||||
requestedInsert = intent.insertRequestOrNull()
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
setContent {
|
||||
// One activity-scoped SettingsViewModel drives both the theme here
|
||||
@@ -92,6 +145,14 @@ class MainActivity : AppCompatActivity() {
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
}
|
||||
// onCreate can only see the night resource qualifier, not the in-app
|
||||
// override — re-apply from the resolved theme so the bar icons follow
|
||||
// the app's light/dark choice.
|
||||
DisposableEffect(darkTheme) {
|
||||
systemBarsDark = darkTheme
|
||||
applyEdgeToEdge()
|
||||
onDispose {}
|
||||
}
|
||||
// The app-wide clock convention: the time-format preference resolved
|
||||
// against the device's 24-hour system setting, provided once here so
|
||||
// every time label reads it via LocalUse24HourFormat.
|
||||
@@ -99,14 +160,38 @@ class MainActivity : AppCompatActivity() {
|
||||
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,
|
||||
hours = settings.timelineHours,
|
||||
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,
|
||||
LocalShowHourGrid provides settings.showHourGrid,
|
||||
LocalTimelineZoom provides timelineZoom,
|
||||
LocalMultiDayCount provides settings.multiDayCount,
|
||||
LocalSoftenColors provides settings.softenColors,
|
||||
) {
|
||||
RootScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -116,6 +201,11 @@ 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
|
||||
@@ -142,6 +232,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.
|
||||
@@ -150,11 +253,23 @@ 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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this launch is the task being resumed rather than a fresh
|
||||
* delivery — Android sets the flag when an activity is started from the
|
||||
* recents list, where the intent it carries is the one the task was started
|
||||
* with however long ago. `onNewIntent` is the channel a real new request
|
||||
* arrives on while the task lives, so nothing is lost by ignoring these.
|
||||
*/
|
||||
private fun Intent.isRelaunch(): Boolean =
|
||||
flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
|
||||
|
||||
/**
|
||||
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
|
||||
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
|
||||
@@ -172,6 +287,46 @@ class MainActivity : AppCompatActivity() {
|
||||
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||
}
|
||||
|
||||
/**
|
||||
* A prefilled new-event form from an external launch asking us to create an
|
||||
* event — another app or widget (e.g. Todo Agenda) firing `ACTION_INSERT`
|
||||
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
|
||||
* event", i.e. create). The new event's fields ride as CalendarContract
|
||||
* extras; anything omitted falls back to the in-app "new event" defaults in
|
||||
* [buildInsertEventForm]. 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/
|
||||
@@ -193,6 +348,12 @@ class MainActivity : AppCompatActivity() {
|
||||
// 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 {
|
||||
@@ -233,6 +394,56 @@ class MainActivity : AppCompatActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The detail key for an external "open this event" — ACTION_VIEW on
|
||||
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
|
||||
* tapping an existing event in the Todo Agenda widget, issue #48). Reuses the
|
||||
* same occurrence-key channel as reminder taps. The launcher passes the
|
||||
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME` when
|
||||
* it has them; a bare URI omits them, so we carry [NO_OCCURRENCE_TIME] and
|
||||
* [EventDetailViewModel] falls back to the event row's own DTSTART/DTEND
|
||||
* rather than rendering at the epoch.
|
||||
*/
|
||||
private fun Intent.viewEventKeyOrNull(): LongArray? {
|
||||
if (action != Intent.ACTION_VIEW) return null
|
||||
val uri = data ?: return null
|
||||
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||
val segments = uri.pathSegments
|
||||
if (segments.firstOrNull() != "events") return null
|
||||
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||
return longArrayOf(
|
||||
eventId,
|
||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
|
||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The occurrence key for an external "edit this event" — `ACTION_EDIT` on
|
||||
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
|
||||
* an assistant, task app, or widget that wants to open the event for editing
|
||||
* rather than viewing). Opens it in the edit form. Reuses the same
|
||||
* occurrence-key channel as reminder/view taps; the caller passes the
|
||||
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME`
|
||||
* when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and
|
||||
* [EventEditViewModel.openForEdit] falls back to the event row's own
|
||||
* DTSTART/DTEND. An id-less `ACTION_EDIT` — or `ACTION_INSERT_OR_EDIT`, which
|
||||
* some apps fire — is a create instead ([insertFormOrNull]).
|
||||
*/
|
||||
private fun Intent.editEventKeyOrNull(): LongArray? {
|
||||
if (action != Intent.ACTION_EDIT && action != Intent.ACTION_INSERT_OR_EDIT) return null
|
||||
val uri = data ?: return null
|
||||
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||
val segments = uri.pathSegments
|
||||
if (segments.firstOrNull() != "events") return null
|
||||
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||
return longArrayOf(
|
||||
eventId,
|
||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
|
||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// The calendar provider's authority/host. A date tap arrives as
|
||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||
@@ -244,6 +455,10 @@ class MainActivity : AppCompatActivity() {
|
||||
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"
|
||||
@@ -307,5 +522,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"
|
||||
}
|
||||
}
|
||||
+52
-17
@@ -1,11 +1,10 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import java.time.Instant
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.allDayLeadDays
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
* Translates an all-day reminder between the **semantic** lead time the UI
|
||||
@@ -32,9 +31,13 @@ private const val MILLIS_PER_MINUTE = 60_000L
|
||||
* event (a whole-day multiple; sub-day remainders are dropped), so it fires at
|
||||
* [timeOfDayMinutes] (minutes from local midnight) in [zone]. The result may be
|
||||
* **negative** — e.g. "at time of event" at 09:00 CEST encodes to −420, meaning
|
||||
* the provider fires *after* DTSTART; this is valid and must not be clamped.
|
||||
* A negative [semanticMinutes] is the "provider default" sentinel and passes
|
||||
* through unchanged.
|
||||
* the provider fires *after* DTSTART; this is valid and must not be clamped. The
|
||||
* one negative it never is is [MINUTES_DEFAULT]: the sentinel and the encoding
|
||||
* share a value space, so an encoding that lands on it is nudged one minute past
|
||||
* it rather than being read back as "use the account default". That sentinel is
|
||||
* also the one [semanticMinutes] that passes through unencoded — a lead time may
|
||||
* itself be negative (a foreign row firing the day *after* the event), and
|
||||
* waving those through would change when they fire.
|
||||
*/
|
||||
internal fun toProviderAllDayMinutes(
|
||||
semanticMinutes: Int,
|
||||
@@ -42,12 +45,13 @@ internal fun toProviderAllDayMinutes(
|
||||
zone: ZoneId,
|
||||
timeOfDayMinutes: Int,
|
||||
): Int {
|
||||
if (semanticMinutes < 0) return semanticMinutes
|
||||
if (semanticMinutes == MINUTES_DEFAULT) return semanticMinutes
|
||||
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
val fire = startDate.minusDays((semanticMinutes / MINUTES_PER_DAY).toLong())
|
||||
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
|
||||
.atZone(zone).toInstant().toEpochMilli()
|
||||
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
||||
val raw = ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
||||
return if (raw == MINUTES_DEFAULT) MINUTES_DEFAULT - 1 else raw
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,21 +75,52 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
|
||||
return LocalDate.of(today.year, month, day)
|
||||
}
|
||||
|
||||
/**
|
||||
* The date an **imported** all-day event's reminder offset is sampled at.
|
||||
*
|
||||
* A one-off fires once, on its own date. A series' `DTSTART` is only an anchor
|
||||
* and may be ancient — Fossify writes a year-less contact birthday at 1970, a
|
||||
* year whose rules predate DST across most of Europe, and a real birth year is
|
||||
* usually older still — so sampling there skews every modern occurrence by the
|
||||
* offset delta (the same trap [nextYearlyOccurrence] exists for). A yearly
|
||||
* series is therefore sampled at its next occurrence of the anchor's month/day,
|
||||
* which shares the season; any other series at [today], which its upcoming
|
||||
* occurrences are near.
|
||||
*
|
||||
* The decode side ([fromProviderAllDayMinutes]) only asks which local *day* the
|
||||
* encoded instant falls on, so it still reads the lead time back correctly from
|
||||
* the anchor's own date.
|
||||
*/
|
||||
internal fun importedAllDayReminderDate(
|
||||
startDate: LocalDate,
|
||||
recurrenceRule: String?,
|
||||
today: LocalDate,
|
||||
): LocalDate = when {
|
||||
recurrenceRule.isNullOrBlank() -> startDate
|
||||
recurrenceRule.contains("FREQ=YEARLY", ignoreCase = true) ->
|
||||
nextYearlyOccurrence(startDate.monthValue, startDate.dayOfMonth, today)
|
||||
startDate.isBefore(today) -> today
|
||||
else -> startDate
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the semantic whole-day lead time from a raw all-day reminder
|
||||
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
|
||||
* returns the right day count regardless of which [timeOfDayMinutes] wrote the
|
||||
* row — including pre-feature rows (raw multiples of 1440, fired at UTC midnight)
|
||||
* and rows written under a different timezone. A negative [rawMinutes] (fire
|
||||
* after DTSTART) folds to day 0.
|
||||
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
|
||||
* detail screen. Delegates to [allDayLeadDays], so the day displayed is the day
|
||||
* the reminder actually fires on.
|
||||
*
|
||||
* [MINUTES_DEFAULT] passes through unchanged, the mirror of the encode side. A
|
||||
* real encoding is often negative, so only that exact value may be treated as the
|
||||
* sentinel — decoding it would land it on the start date and turn "use the
|
||||
* account default" into a concrete at-start alarm.
|
||||
*/
|
||||
internal fun fromProviderAllDayMinutes(
|
||||
rawMinutes: Int,
|
||||
startDate: LocalDate,
|
||||
zone: ZoneId,
|
||||
): Int {
|
||||
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
val fireLocalDate = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE)
|
||||
.atZone(zone).toLocalDate()
|
||||
return ChronoUnit.DAYS.between(fireLocalDate, startDate).toInt() * MINUTES_PER_DAY
|
||||
timeOfDayMinutes: Int,
|
||||
): Int = if (rawMinutes == MINUTES_DEFAULT) {
|
||||
rawMinutes
|
||||
} else {
|
||||
allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
|
||||
}
|
||||
|
||||
+28
-15
@@ -1,22 +1,35 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
/**
|
||||
* Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
|
||||
* `EVENT_COLOR`. The named entries exist for callers that need one specific
|
||||
* hue (the managed special-dates calendars), so they can't drift from the
|
||||
* swatches offered in the colour picker.
|
||||
* The colours offered when creating or editing a calendar; ARGB ints for a raw
|
||||
* `CALENDAR_COLOR` / `EVENT_COLOR`.
|
||||
*
|
||||
* Hues rather than final fills: every surface that draws a calendar colour runs
|
||||
* it through the app's own tone pass first, so these are chosen to stay
|
||||
* distinguishable after it. Shared with Agendula, whose list palette these are —
|
||||
* the two apps are the same family and a calendar and a task list picked from
|
||||
* different-looking sets read as different products.
|
||||
*
|
||||
* The named entries exist for callers that need one specific hue (the managed
|
||||
* special-dates calendars), so they can't drift from the swatches on offer.
|
||||
*/
|
||||
object CalendarColorPalette {
|
||||
val Red = 0xFFD50000.toInt()
|
||||
val Orange = 0xFFE67C00.toInt()
|
||||
val Amber = 0xFFF6BF26.toInt()
|
||||
val Green = 0xFF33B679.toInt()
|
||||
val DarkGreen = 0xFF0B8043.toInt()
|
||||
val Blue = 0xFF039BE5.toInt()
|
||||
val Indigo = 0xFF3F51B5.toInt()
|
||||
val Purple = 0xFF8E24AA.toInt()
|
||||
val Graphite = 0xFF616161.toInt()
|
||||
val Mauve = 0xFF7A5C6B.toInt()
|
||||
val Red = 0xFFD7484A.toInt()
|
||||
val Orange = 0xFFE8743B.toInt()
|
||||
val Amber = 0xFFE0A32E.toInt()
|
||||
val Olive = 0xFF7CA83E.toInt()
|
||||
val Green = 0xFF35A06A.toInt()
|
||||
val Teal = 0xFF19938C.toInt()
|
||||
val Cyan = 0xFF2A9BC4.toInt()
|
||||
val Blue = 0xFF3C74C8.toInt()
|
||||
val Indigo = 0xFF6A5CC0.toInt()
|
||||
val Purple = 0xFF9455B8.toInt()
|
||||
val Pink = 0xFFC94F8E.toInt()
|
||||
|
||||
/** The full palette, in swatch-row order. */
|
||||
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
|
||||
/** The full palette, in swatch-grid order. */
|
||||
val all: List<Int> = listOf(
|
||||
Mauve, Red, Orange, Amber, Olive, Green,
|
||||
Teal, Cyan, Blue, Indigo, Purple, Pink,
|
||||
)
|
||||
}
|
||||
|
||||
+703
-107
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,9 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
||||
val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
return CalendarSource(
|
||||
id = getLong(CalendarProjection.IDX_ID),
|
||||
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
|
||||
?: Fallbacks.UNNAMED_CALENDAR,
|
||||
// Blank when the provider row has no name: the placeholder is a display
|
||||
// string and belongs to the UI, where it can be localised (#329).
|
||||
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME).orEmpty(),
|
||||
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
|
||||
accountType = accountType,
|
||||
color = getInt(CalendarProjection.IDX_COLOR),
|
||||
@@ -31,5 +32,8 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
||||
isManaged = isLocal &&
|
||||
getString(CalendarProjection.IDX_MANAGED_MARKER)
|
||||
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
|
||||
// NULL is treated as syncing — the harmless default.
|
||||
syncsEvents = isNull(CalendarProjection.IDX_SYNC_EVENTS) ||
|
||||
getInt(CalendarProjection.IDX_SYNC_EVENTS) != 0,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
@@ -17,11 +18,11 @@ interface CalendarRepository {
|
||||
suspend fun eventDetail(eventId: Long): EventDetail
|
||||
|
||||
/**
|
||||
* Events whose title, description or location contains [query], with hidden
|
||||
* calendars removed and newest first. Empty when [query] is blank. Searches
|
||||
* the whole history/future (see [CalendarDataSource.searchEvents]).
|
||||
* Candidate matches for [query] with hidden calendars removed; empty when
|
||||
* [query] is blank. Searches the whole history/future and leaves matching
|
||||
* and ranking to [de.jeanlucmakiola.calendula.domain.EventSearch].
|
||||
*/
|
||||
suspend fun searchEvents(query: String): List<EventInstance>
|
||||
suspend fun searchEvents(query: String): List<SearchCandidate>
|
||||
|
||||
/**
|
||||
* The event-colour palette a calendar's account publishes; empty when it
|
||||
@@ -38,11 +39,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
|
||||
@@ -61,10 +73,30 @@ interface CalendarRepository {
|
||||
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
||||
|
||||
/**
|
||||
* Change a single occurrence of a recurring event (exception row with the
|
||||
* form's values); returns the exception's `Events._ID`.
|
||||
* 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 updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
|
||||
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`. [original] is what
|
||||
* the row holds now, for the case where it turns out not to recur.
|
||||
*/
|
||||
suspend fun updateOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
form: EventForm,
|
||||
): Long
|
||||
|
||||
/**
|
||||
* Change a recurring event from [beginMillis] onwards (series split);
|
||||
|
||||
+189
-42
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
@@ -8,6 +10,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -15,11 +18,18 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -46,54 +56,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)
|
||||
}
|
||||
}
|
||||
|
||||
override fun calendars(): Flow<List<CalendarSource>> =
|
||||
ticks
|
||||
.onStart { emit(Unit) }
|
||||
.reQuery { dataSource.calendars() }
|
||||
.flowOn(io)
|
||||
/**
|
||||
* Re-query signal for everything filtered by visibility: the provider's own
|
||||
* notifications, plus every change to the pending switch-off set.
|
||||
*/
|
||||
private fun visibilityTicks(): Flow<Unit> = merge(
|
||||
ticks.onStart { emit(Unit) },
|
||||
// drop(1): the current value is already covered by the tick above.
|
||||
prefs.pendingDisabledCalendarIds.drop(1).map {},
|
||||
)
|
||||
|
||||
// Instances are filtered by the app-side hidden ∪ disabled calendar sets
|
||||
// (M3): an event is dropped whenever the user has hidden *or* disabled its
|
||||
// calendar. Re-runs when the provider ticks *or* either set changes —
|
||||
// toggling a calendar in the filter sheet or the calendar manager updates
|
||||
// every view immediately. [calendars] stays unfiltered so those screens can
|
||||
// list and re-enable hidden/disabled calendars.
|
||||
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
||||
combine(
|
||||
ticks
|
||||
.onStart { emit(Unit) }
|
||||
.reQuery {
|
||||
dataSource.instances(
|
||||
beginMillis = range.start.toEpochMillis(),
|
||||
endMillis = range.endInclusive.toEpochMillis(),
|
||||
)
|
||||
},
|
||||
prefs.hiddenCalendarIds,
|
||||
prefs.disabledCalendarIds,
|
||||
) { instances, hidden, disabled ->
|
||||
val excluded = hidden + disabled
|
||||
if (excluded.isEmpty()) instances
|
||||
else instances.filterNot { it.calendarId in excluded }
|
||||
// A switch-off not yet written to the provider is folded into the flag
|
||||
// itself, so every consumer reads one visibility (#75). The reconciler goes
|
||||
// to the data source directly — it needs the provider's own answer.
|
||||
override fun calendars(): Flow<List<CalendarSource>> =
|
||||
visibilityTicks().reQuery {
|
||||
val calendars = calendarsSnapshot()
|
||||
val pendingDisabled = prefs.pendingDisabledCalendarIds.first()
|
||||
if (pendingDisabled.isEmpty()) calendars
|
||||
else calendars.map {
|
||||
if (it.id in pendingDisabled) it.copy(isVisibleInSystem = false) else it
|
||||
}
|
||||
}
|
||||
// hidden and disabled both derive from one DataStore, so toggling
|
||||
// either makes both re-emit and combine briefly surfaces the same
|
||||
// list twice — collapse the duplicate so views don't re-render for it.
|
||||
.distinctUntilChanged()
|
||||
.flowOn(io)
|
||||
|
||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||
// Instances are filtered by the system's VISIBLE flag ∪ the switch-offs
|
||||
// still waiting to be written to it ∪ the app-side hidden set from the
|
||||
// filter sheet. [calendars] stays unfiltered so those screens can list and
|
||||
// re-enable invisible calendars.
|
||||
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
||||
combine(
|
||||
visibilityTicks().reQuery {
|
||||
// All three reads in one pass, so a list of instances is never
|
||||
// filtered against a visibility snapshot from another tick.
|
||||
QueriedInstances(
|
||||
instances = dataSource.instances(
|
||||
beginMillis = range.start.toEpochMillis(),
|
||||
endMillis = range.endInclusive.toEpochMillis(),
|
||||
),
|
||||
switchedOffCalendarIds = invisibleCalendarIds() +
|
||||
prefs.pendingDisabledCalendarIds.first(),
|
||||
)
|
||||
},
|
||||
prefs.hiddenCalendarIds,
|
||||
) { queried, hidden ->
|
||||
val excluded = hidden + queried.switchedOffCalendarIds
|
||||
if (excluded.isEmpty()) queried.instances
|
||||
else queried.instances.filterNot { it.calendarId in excluded }
|
||||
}
|
||||
// Any DataStore edit re-emits the hidden set even when unchanged;
|
||||
// collapse those so views don't re-render for them.
|
||||
.distinctUntilChanged()
|
||||
.flowOn(io)
|
||||
|
||||
/** One instances query plus the visibility it must be filtered against. */
|
||||
private data class QueriedInstances(
|
||||
val instances: List<EventInstance>,
|
||||
val switchedOffCalendarIds: Set<Long>,
|
||||
)
|
||||
|
||||
/** Calendars switched off at system level — hidden, and never reminded about. */
|
||||
private suspend fun invisibleCalendarIds(): Set<Long> = calendarsSnapshot()
|
||||
.filterNot { it.isVisibleInSystem }
|
||||
.mapTo(mutableSetOf()) { it.id }
|
||||
|
||||
private val calendarsLock = Mutex()
|
||||
private var cachedGeneration = -1L
|
||||
private var cachedPending: Set<Long>? = null
|
||||
private var cachedCalendars: List<CalendarSource> = emptyList()
|
||||
|
||||
/**
|
||||
* The calendar list for the current tick, queried once and shared, so every
|
||||
* open view doesn't pay for its own `Calendars` query and all of them see
|
||||
* one snapshot.
|
||||
*
|
||||
* An empty result is never cached — that is what a read without the calendar
|
||||
* permission returns, and the grant itself doesn't notify the provider. The
|
||||
* pending switch-off set keys the cache alongside the tick, because an id
|
||||
* leaves it before the invalidating observer is dispatched.
|
||||
*/
|
||||
private suspend fun calendarsSnapshot(): List<CalendarSource> = calendarsLock.withLock {
|
||||
val current = generation.get()
|
||||
val pending = prefs.pendingDisabledCalendarIds.first()
|
||||
if (current != cachedGeneration || pending != cachedPending || cachedCalendars.isEmpty()) {
|
||||
cachedCalendars = dataSource.calendars()
|
||||
cachedGeneration = current
|
||||
cachedPending = pending
|
||||
}
|
||||
cachedCalendars
|
||||
}
|
||||
|
||||
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||
dataSource.eventDetail(eventId, allDayReminderTimeMinutes())
|
||||
?: throw NoSuchEventException(eventId)
|
||||
}
|
||||
|
||||
override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) {
|
||||
if (query.isBlank()) return@withContext emptyList()
|
||||
val excluded = prefs.hiddenCalendarIds.first() + prefs.disabledCalendarIds.first()
|
||||
val excluded = prefs.hiddenCalendarIds.first() +
|
||||
prefs.pendingDisabledCalendarIds.first() +
|
||||
invisibleCalendarIds()
|
||||
dataSource.searchEvents(query)
|
||||
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
||||
.let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } }
|
||||
}
|
||||
|
||||
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
@@ -117,26 +195,77 @@ 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, allDayReminderTimeMinutes()) }
|
||||
|
||||
override suspend fun importEvents(
|
||||
targetCalendarId: Long,
|
||||
events: List<ParsedIcsEvent>,
|
||||
): IcsImportSummary = withContext(io) {
|
||||
val existing = dataSource.existingUids(targetCalendarId)
|
||||
// Both are per-calendar, not per-event: looking them up once keeps a
|
||||
// thousand-event restore to two extra queries. The palette is the
|
||||
// uncurated one — an imported colour is matched against every key the
|
||||
// account accepts, not the subset the picker shows.
|
||||
val palette = dataSource.publishedEventColors(targetCalendarId)
|
||||
val allDayMinutes = allDayReminderTimeMinutes()
|
||||
var imported = 0
|
||||
var skipped = 0
|
||||
for (event in events) {
|
||||
var failed = 0
|
||||
var notAttempted = 0
|
||||
for ((index, event) in events.withIndex()) {
|
||||
// A known UID means the event is already in this calendar — skip,
|
||||
// keeping a restore idempotent (no overwrite this pass).
|
||||
if (event.uid != null && event.uid in existing) {
|
||||
skipped++
|
||||
} else {
|
||||
dataSource.insertImportedEvent(event, targetCalendarId)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
dataSource.insertImportedEvent(event, targetCalendarId, allDayMinutes, palette)
|
||||
imported++
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
// Not this event's fault: the permission or the target calendar is
|
||||
// gone, so every remaining insert would fail the same way.
|
||||
Log.w(TAG, "Import stopped: the calendar is no longer writable", e)
|
||||
// A known UID would have been skipped whatever happened, so it is
|
||||
// no part of what a retry still has to import.
|
||||
val remaining = events.drop(index)
|
||||
val duplicates = remaining.count { it.uid?.let { uid -> uid in existing } == true }
|
||||
skipped += duplicates
|
||||
notAttempted = remaining.size - duplicates
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
// One event the provider won't take must not cost the user the
|
||||
// rest of the file — foreign exports do carry rows it rejects
|
||||
// outright (a malformed RRULE throws straight out of insert).
|
||||
failed++
|
||||
Log.w(TAG, "Skipped an unimportable event", e)
|
||||
}
|
||||
}
|
||||
IcsImportSummary(imported = imported, skippedDuplicate = skipped)
|
||||
IcsImportSummary(
|
||||
imported = imported,
|
||||
skippedDuplicate = skipped,
|
||||
failed = failed,
|
||||
notAttempted = notAttempted,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||
@@ -155,12 +284,26 @@ 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,
|
||||
original: EventForm,
|
||||
form: EventForm,
|
||||
): Long = withContext(io) {
|
||||
dataSource.updateOccurrence(eventId, beginMillis, form, allDayReminderTimeMinutes())
|
||||
dataSource.updateOccurrence(
|
||||
eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun updateEventFromOccurrence(
|
||||
@@ -184,6 +327,10 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
) = withContext(io) {
|
||||
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "CalendarRepository"
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.calendarVisibilityPlan
|
||||
import de.jeanlucmakiola.calendula.domain.hasSystemHiddenCalendars
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Keeps [CalendarPrefs.pendingDisabledCalendarIds] and the system's
|
||||
* `Calendars.VISIBLE` in step (#75): the fold-in of the retired app-local
|
||||
* visibility model, and the standing drain for switch-offs made without
|
||||
* `WRITE_CALENDAR`.
|
||||
*
|
||||
* Runs on every launch and on every calendar-permission grant; a no-op once the
|
||||
* pending set is empty and the notice is settled. Only ever hides (see
|
||||
* [calendarVisibilityPlan]); on an upgraded install the first run that sees a
|
||||
* system-hidden calendar arms the one-time explanatory notice.
|
||||
*/
|
||||
@Singleton
|
||||
class CalendarVisibilityReconciler @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val dataSource: CalendarDataSource,
|
||||
private val prefs: CalendarPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
suspend fun run() = withContext(io) {
|
||||
// The DataStore reads sit inside the guard too: this runs in a bare
|
||||
// application-scope coroutine, so an IOException from a damaged
|
||||
// preferences file would take the process down on every launch.
|
||||
try {
|
||||
// Settled ahead of the permission gate, so an update installed
|
||||
// before the first grant can't later look like an upgrade.
|
||||
if (!isUpgradeInstall()) settleNoticeOnce(pending = false)
|
||||
if (!hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
|
||||
val pending = prefs.pendingDisabledCalendarIds.first()
|
||||
val noticeSettled = prefs.visibilityNoticePending.first() != null
|
||||
if (pending.isEmpty() && noticeSettled) return@withContext
|
||||
val calendars = dataSource.calendars()
|
||||
// Empty means "couldn't read" (null cursor), not "no calendars".
|
||||
// Both decisions below are one-way, so leave them to the next run.
|
||||
if (calendars.isEmpty()) return@withContext
|
||||
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
|
||||
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
||||
return@withContext
|
||||
}
|
||||
val plan = calendarVisibilityPlan(calendars, pending)
|
||||
prefs.removePendingDisabledCalendarIds(plan.settled)
|
||||
// One calendar per write: the provider skips its reminder-alarm
|
||||
// reschedule for anything but a single-id update (see
|
||||
// [CalendarDataSource.setCalendarVisible]).
|
||||
for (id in plan.hide) {
|
||||
dataSource.setCalendarVisible(id, false)
|
||||
prefs.removePendingDisabledCalendarIds(setOf(id))
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Calendar visibility reconcile failed; will retry", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle the one-time notice: [pending] arms it, false retires it unshown.
|
||||
* Answered once and stored either way, so it can't resurface later.
|
||||
*/
|
||||
private suspend fun settleNoticeOnce(pending: Boolean) {
|
||||
if (prefs.visibilityNoticePending.first() != null) return
|
||||
prefs.setVisibilityNoticePending(pending)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this install has ever run an earlier version. The notice explains
|
||||
* a behaviour change, so a first install has nothing to announce.
|
||||
*/
|
||||
private fun isUpgradeInstall(): Boolean = try {
|
||||
@Suppress("DEPRECATION")
|
||||
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
info.lastUpdateTime > info.firstInstallTime
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
Log.w(TAG, "Own package info unavailable; treating as a fresh install", e)
|
||||
false
|
||||
}
|
||||
|
||||
private fun hasPermission(permission: String): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/** Lets non-injectable entry points (the Application) reach the reconciler. */
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Deps {
|
||||
fun calendarVisibilityReconciler(): CalendarVisibilityReconciler
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "CalendarVisibility"
|
||||
}
|
||||
}
|
||||
@@ -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? {
|
||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||
|
||||
if (begin < 0L) {
|
||||
Log.w(TAG, "Dropping event with negative dtstart=$begin")
|
||||
// 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)
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -110,6 +125,7 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||
eventColor = eventColor,
|
||||
eventColorKey = eventColorKey,
|
||||
isException = !isNull(EventDetailProjection.IDX_ORIGINAL_ID),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,23 @@ package de.jeanlucmakiola.calendula.data.calendar
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.nearestTo
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toJavaLocalDateTime
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.ResolverStyle
|
||||
import java.time.LocalDate as JavaLocalDate
|
||||
import java.time.LocalDateTime as JavaLocalDateTime
|
||||
import java.time.LocalTime as JavaLocalTime
|
||||
|
||||
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
||||
internal data class EventWriteTimes(
|
||||
@@ -20,7 +31,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 +46,56 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
||||
timezone = "UTC",
|
||||
)
|
||||
} else {
|
||||
val writeZone = writeZone(zone)
|
||||
EventWriteTimes(
|
||||
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
||||
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
||||
timezone = zone.id,
|
||||
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||
timezone = writeZone.id,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The zone this form's DTSTART is expressed in: UTC for an all-day event (the
|
||||
* provider's date anchor), otherwise the form's own pinned zone, falling back to
|
||||
* [deviceZone] when it doesn't pin one or pins something the tz database can't
|
||||
* parse.
|
||||
*/
|
||||
private fun EventForm.writeZone(deviceZone: ZoneId): ZoneId = if (isAllDay) {
|
||||
ZoneOffset.UTC
|
||||
} else {
|
||||
timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: deviceZone
|
||||
}
|
||||
|
||||
/**
|
||||
* The form's start as a bare wall-clock value — what the user sees on the form,
|
||||
* stripped of any zone. All-day events use their date's midnight rather than
|
||||
* [EventForm.start]'s placeholder time-of-day, which exists only so switching the
|
||||
* event back to timed has something to show.
|
||||
*/
|
||||
private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) {
|
||||
start.date.toJavaLocalDate().atStartOfDay()
|
||||
} else {
|
||||
start.toJavaLocalDateTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 2445 duration for a recurring event's row (the provider requires
|
||||
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
||||
* events, seconds otherwise.
|
||||
*/
|
||||
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) {
|
||||
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D"
|
||||
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String =
|
||||
rfc2445Duration(dtEndMillis - dtStartMillis, isAllDay)
|
||||
|
||||
/**
|
||||
* RFC 2445 duration for a [spanMillis]-long event: whole days for all-day
|
||||
* events (the provider's convention), seconds otherwise. Shared by the write
|
||||
* paths that need a DURATION but start from a raw millisecond span (the series
|
||||
* copy and exception replay of a calendar move) rather than [EventWriteTimes].
|
||||
*/
|
||||
internal fun rfc2445Duration(spanMillis: Long, isAllDay: Boolean): String = if (isAllDay) {
|
||||
"P${spanMillis / MILLIS_PER_DAY}D"
|
||||
} else {
|
||||
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
||||
"P${spanMillis / 1_000L}S"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,16 +143,21 @@ internal fun buildEventInsertValues(
|
||||
*
|
||||
* Time fields travel together (the provider validates them as a unit):
|
||||
* - unchanged times, all-day flag and rrule → no time columns at all;
|
||||
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
||||
* - 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.
|
||||
* - non-recurring result → DTSTART/DTEND, DURATION, RRULE and EXDATE cleared;
|
||||
* - recurring result → the *series* DTSTART moves by the same **wall-clock**
|
||||
* shift the user applied to the displayed occurrence and is re-resolved in the
|
||||
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
|
||||
* replaces DTEND, RRULE is written, and the row's exclusions
|
||||
* ([seriesExdate], the current EXDATE) travel with the anchor. This keeps past
|
||||
* occurrences intact when someone edits a later occurrence's time, and keeps
|
||||
* the anchor's time-of-day stable across a DST boundary or a zone change
|
||||
* between the two.
|
||||
*/
|
||||
internal fun buildEventUpdateValues(
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
seriesDtStartMillis: Long,
|
||||
seriesExdate: String?,
|
||||
zone: ZoneId,
|
||||
): Map<String, Any?> = buildMap {
|
||||
if (updated.title.trim() != original.title.trim()) {
|
||||
@@ -125,10 +179,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)
|
||||
@@ -139,12 +197,39 @@ internal fun buildEventUpdateValues(
|
||||
put(CalendarContract.Events.DTEND, newTimes.dtEndMillis)
|
||||
put(CalendarContract.Events.RRULE, null)
|
||||
put(CalendarContract.Events.DURATION, null)
|
||||
// The exclusions named occurrences of a series that no longer exists.
|
||||
// Left behind they are dormant rather than harmless: adding a recurrence
|
||||
// back later would punch the old holes into the new one.
|
||||
put(CalendarContract.Events.EXDATE, null)
|
||||
} else {
|
||||
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))
|
||||
// An EXDATE stamp is an absolute instant, so it excludes an occurrence
|
||||
// only while the series keeps generating one at exactly that instant. The
|
||||
// anchor has just moved, so every occurrence regenerates elsewhere and a
|
||||
// stamp left behind matches none of them — the occurrence the user deleted
|
||||
// comes back. Move the stamps the same way the anchor moved.
|
||||
shiftedExdate(seriesExdate, original, updated, zone)
|
||||
?.takeIf { it != seriesExdate }
|
||||
?.let { put(CalendarContract.Events.EXDATE, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +247,10 @@ internal fun buildEventUpdateValues(
|
||||
* DTSTART + DURATION itself, clearing the inherited RRULE in the process. This
|
||||
* matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made
|
||||
* "only this event" fail on-device (Codeberg #16).
|
||||
*
|
||||
* A palette key is left out: the provider crashes on an exception insert that
|
||||
* carries `EVENT_COLOR_KEY` (Codeberg #338), so the caller sets it with a plain
|
||||
* update once the row exists.
|
||||
*/
|
||||
internal fun buildOccurrenceExceptionValues(
|
||||
form: EventForm,
|
||||
@@ -179,9 +268,517 @@ internal fun buildOccurrenceExceptionValues(
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
|
||||
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
|
||||
putAll(eventColorColumns(form.colorKey, form.color))
|
||||
if (form.colorKey == null) putAll(eventColorColumns(null, form.color))
|
||||
}
|
||||
|
||||
/**
|
||||
* The form as a **detached occurrence**: the same edited values, with the
|
||||
* series rule dropped so [buildEventInsertValues] writes a standalone one-off
|
||||
* row (DTSTART + DTEND, no RRULE/DURATION) at the occurrence's own times.
|
||||
*
|
||||
* The "edit only this event" shape for a series with **no `_sync_id`**, where an
|
||||
* exception row can't attach to its parent at all (Codeberg #234).
|
||||
*
|
||||
* The exception path gets the rule dropped for free — the provider clears the
|
||||
* RRULE it cloned when an exception carries DTSTART + DURATION
|
||||
* ([buildOccurrenceExceptionValues]). Here nothing is cloned, so it is stripped
|
||||
* by hand; leaving it on would insert a second *series* overlapping the first.
|
||||
*/
|
||||
internal fun EventForm.toDetachedOccurrence(): EventForm = copy(rrule = null)
|
||||
|
||||
/**
|
||||
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
|
||||
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
||||
* 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.
|
||||
*
|
||||
* [rdate] travels with [rrule]: a sync adapter may list a series' occurrences
|
||||
* instead of naming a rule, and rewriting the recurrence set without it would
|
||||
* leave the provider nothing but DTSTART to re-expand from — the collapse this
|
||||
* values map exists to avoid.
|
||||
*
|
||||
* EXDATE is a comma-separated list, so an existing one is appended to (a repeat
|
||||
* of the same occurrence is folded away — [mergedExdate]).
|
||||
*/
|
||||
internal fun buildOccurrenceExdateValues(
|
||||
existingExdate: String?,
|
||||
occurrenceMillis: Long,
|
||||
dtStartMillis: Long,
|
||||
rrule: String?,
|
||||
rdate: String?,
|
||||
duration: String?,
|
||||
timezone: String?,
|
||||
allDay: Int,
|
||||
): Map<String, Any?> = mapOf(
|
||||
CalendarContract.Events.EXDATE to
|
||||
mergedExdate(existingExdate, occurrenceMillis, allDay != 0, timezone),
|
||||
CalendarContract.Events.DTSTART to dtStartMillis,
|
||||
CalendarContract.Events.RRULE to rrule,
|
||||
CalendarContract.Events.RDATE to rdate,
|
||||
CalendarContract.Events.DURATION to duration,
|
||||
CalendarContract.Events.EVENT_TIMEZONE to timezone,
|
||||
CalendarContract.Events.ALL_DAY to allDay,
|
||||
)
|
||||
|
||||
/**
|
||||
* [existingExdate] with the occurrence at [occurrenceMillis] excluded as well.
|
||||
*
|
||||
* Existing stamps are canonicalised first — all-day series take the `VALUE=DATE`
|
||||
* form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`) — so
|
||||
* appending to a list a sync adapter wrote in the other accepted form folds the
|
||||
* repeat away instead of leaving the same day excluded twice in two spellings.
|
||||
*
|
||||
* A stamp in no form Calendula reads makes the whole list untouchable: it is kept
|
||||
* exactly as it is, behind the parameter prefix that tells a reader how to
|
||||
* resolve it, and the new exclusion joins it in the list's own form.
|
||||
* Canonicalising the readable stamps around it would leave absolute stamps under
|
||||
* a prefix claiming a zone for all of them — which is how the unreadable one
|
||||
* quietly changes instant.
|
||||
*/
|
||||
private fun mergedExdate(
|
||||
existingExdate: String?,
|
||||
occurrenceMillis: Long,
|
||||
isAllDay: Boolean,
|
||||
timezone: String?,
|
||||
): String {
|
||||
val listZone = exdateListZone(existingExdate, timezone)
|
||||
val stamps = exdateStamps(existingExdate)
|
||||
val canonical = stamps.map { canonicalExdateStamp(it, isAllDay, listZone) }
|
||||
if (canonical.all { it != null }) {
|
||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
|
||||
return (canonical.filterNotNull() + stamp).distinct().joinToString(",")
|
||||
}
|
||||
val prefix = existingExdate?.exdateParameterPrefix().orEmpty()
|
||||
val stamp = if (isAllDay || prefix.isEmpty()) {
|
||||
formatExdateStamp(occurrenceMillis, isAllDay)
|
||||
} else {
|
||||
Instant.ofEpochMilli(occurrenceMillis).atZone(listZone).format(FLOATING_EXDATE)
|
||||
}
|
||||
return prefix + (stamps + stamp).distinct().joinToString(",")
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [existingExdate] already excludes the occurrence at [occurrenceMillis]
|
||||
* — i.e. it has already been dropped from the series, deleted or detached.
|
||||
*
|
||||
* Guards the detach path against running twice from a stale screen: the EXDATE
|
||||
* merge folds the repeat away silently and the update still reports one row
|
||||
* changed, so a second save would leave a second standalone copy.
|
||||
*/
|
||||
internal fun exdateContains(
|
||||
existingExdate: String?,
|
||||
occurrenceMillis: Long,
|
||||
isAllDay: Boolean,
|
||||
timezone: String?,
|
||||
): Boolean {
|
||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
|
||||
val listZone = exdateListZone(existingExdate, timezone)
|
||||
// Canonicalised rather than compared verbatim: a sync adapter may spell the
|
||||
// same exclusion differently to Calendula, and a guard that misses it lets
|
||||
// the detach run a second time and leave a second standalone copy.
|
||||
return exdateStamps(existingExdate)
|
||||
.any { canonicalExdateStamp(it, isAllDay, listZone) == stamp }
|
||||
}
|
||||
|
||||
/**
|
||||
* [existingExdate] with every stamp re-timed by the same **wall-clock** shift the
|
||||
* series anchor takes from [original] to [updated] — the exclusions' half of the
|
||||
* anchor move [buildEventUpdateValues] performs, and what carries them onto the
|
||||
* new series when "this and following" splits one.
|
||||
*
|
||||
* A stamp is an absolute instant, so it keeps naming its occurrence only if it
|
||||
* moves exactly as the occurrence does: shifted in wall clock and re-resolved in
|
||||
* the event's (possibly new) zone. A millisecond delta would instead bake in the
|
||||
* offset that happened to apply at the edited occurrence — an hour off for any
|
||||
* exclusion on the far side of a DST boundary. All-day-ness is read from
|
||||
* [original] and written from [updated], so the `VALUE=DATE` and date-time forms
|
||||
* convert into each other when the event switches.
|
||||
*
|
||||
* A zoned list is read in the zone its prefix names ([exdateListZone]). Null
|
||||
* when there is nothing to carry: no stamps, or a stamp in a form Calendula
|
||||
* cannot read at all. Those are left exactly as they are rather than guessed at —
|
||||
* a stale stamp excludes nothing, but a mangled one could exclude the wrong
|
||||
* occurrence.
|
||||
*/
|
||||
internal fun shiftedExdate(
|
||||
existingExdate: String?,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
zone: ZoneId,
|
||||
): String? {
|
||||
val stamps = exdateStamps(existingExdate)
|
||||
if (stamps.isEmpty()) return null
|
||||
val fromZone = original.writeZone(zone)
|
||||
val toZone = updated.writeZone(zone)
|
||||
val listZone = exdateListZone(existingExdate, fromZone.id)
|
||||
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
|
||||
return stamps
|
||||
.map { stamp ->
|
||||
val local = parseExdateStamp(stamp, original.isAllDay, fromZone, listZone)
|
||||
?: return null
|
||||
formatExdateStamp(local.plus(wallClockShift), updated.isAllDay, toZone)
|
||||
}
|
||||
.distinct()
|
||||
.joinToString(",")
|
||||
}
|
||||
|
||||
/**
|
||||
* The stamps of [existingExdate] naming occurrences after [beginMillis] — the ones
|
||||
* that belong to the *new* series once "this and following" splits a recurring
|
||||
* event there. The parent keeps the full list; its stamps past the split point are
|
||||
* simply inert once it stops generating those occurrences.
|
||||
*
|
||||
* The occurrence at [beginMillis] itself is never carried. It is the one the user
|
||||
* is editing, so it exists by definition, and honouring a stale exclusion for it
|
||||
* would swallow the edit whole.
|
||||
*
|
||||
* The stamps that qualify come back canonicalised, since the zone prefix that
|
||||
* told the reader how to resolve them does not survive the filter.
|
||||
*
|
||||
* Null when nothing qualifies, or when a stamp can't be read (see [shiftedExdate]).
|
||||
*/
|
||||
internal fun exdateAfter(
|
||||
existingExdate: String?,
|
||||
beginMillis: Long,
|
||||
isAllDay: Boolean,
|
||||
timezone: String?,
|
||||
): String? {
|
||||
val stamps = exdateStamps(existingExdate)
|
||||
if (stamps.isEmpty()) return null
|
||||
val listZone = exdateListZone(existingExdate, timezone)
|
||||
return stamps
|
||||
.mapNotNull { stamp ->
|
||||
val utc = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC, listZone) ?: return null
|
||||
utc.takeIf { it.toInstant(ZoneOffset.UTC).toEpochMilli() > beginMillis }
|
||||
}
|
||||
.map { formatExdateStamp(it, isAllDay, ZoneOffset.UTC) }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.joinToString(",")
|
||||
}
|
||||
|
||||
/**
|
||||
* The individual stamps of an EXDATE column value; it is a comma-separated list,
|
||||
* optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of
|
||||
* a zoned one ([exdateListZone] reads that half).
|
||||
*/
|
||||
private fun exdateStamps(exdate: String?): List<String> = exdate
|
||||
?.withoutExdateParameters()
|
||||
?.split(',')
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
.orEmpty()
|
||||
|
||||
/**
|
||||
* An EXDATE column value with the parameter section its stamps sit behind taken
|
||||
* off.
|
||||
*/
|
||||
internal fun String.withoutExdateParameters(): String = substring(exdateParameterPrefix().length)
|
||||
|
||||
/**
|
||||
* The parameter section an EXDATE list's stamps sit behind, its `;` included, or
|
||||
* empty when it has none. Only a `;` before the first stamp separator is one —
|
||||
* anything later belongs to a stamp, and cutting there would drop every stamp in
|
||||
* front of it.
|
||||
*/
|
||||
private fun String.exdateParameterPrefix(): String {
|
||||
val semicolon = indexOf(';')
|
||||
val comma = indexOf(',')
|
||||
return if (semicolon >= 0 && (comma < 0 || semicolon < comma)) {
|
||||
substring(0, semicolon + 1)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The zone the floating stamps of [exdate] are wall clock in: the one its
|
||||
* parameter prefix names, or [fallback] — the event's own `EVENT_TIMEZONE` —
|
||||
* when there is no prefix, or the device's tz database doesn't know the name it
|
||||
* uses (Exchange spells zones its own way). The series' own zone is the right
|
||||
* guess, since a zoned exclusion list is written in it. UTC last, for a row that
|
||||
* carries no zone either. Stamps already written as UTC instants ignore all this.
|
||||
*
|
||||
* Both spellings of the prefix are read. AOSP's `RecurrenceSet` stores the TZID
|
||||
* parameter's *value* alone (`Europe/Berlin;<stamps>`), and so does DAVx5; the
|
||||
* iCalendar `TZID=<zone>;` some writers keep is the other one. Only matching the
|
||||
* second would miss every list the provider itself wrote.
|
||||
*/
|
||||
internal fun exdateListZone(exdate: String?, fallback: String?): ZoneId =
|
||||
exdate?.exdateZoneName()?.toZoneIdOrNull()
|
||||
?: fallback?.toZoneIdOrNull()
|
||||
?: ZoneOffset.UTC
|
||||
|
||||
/** The zone id an EXDATE list's parameter prefix names, with or without `TZID=`. */
|
||||
private fun String.exdateZoneName(): String {
|
||||
val prefix = exdateParameterPrefix().dropLast(1)
|
||||
return if (prefix.startsWith(TZID_PREFIX, ignoreCase = true)) {
|
||||
prefix.drop(TZID_PREFIX.length)
|
||||
} else {
|
||||
prefix
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toZoneIdOrNull(): ZoneId? = runCatching { ZoneId.of(this) }.getOrNull()
|
||||
|
||||
/** The optional `TZID=` a zoned EXDATE list's zone id may sit behind. */
|
||||
private const val TZID_PREFIX = "TZID="
|
||||
|
||||
/**
|
||||
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
|
||||
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
|
||||
* reads off the UTC calendar day.
|
||||
*/
|
||||
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String =
|
||||
formatExdateStamp(
|
||||
local = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC).toLocalDateTime(),
|
||||
isAllDay = isAllDay,
|
||||
zone = ZoneOffset.UTC,
|
||||
)
|
||||
|
||||
/**
|
||||
* One EXDATE entry for the occurrence whose wall clock in [zone] is [local]. An
|
||||
* all-day entry keeps only the date (its time-of-day is the anchor's, not the
|
||||
* occurrence's); a timed one is resolved in [zone] and written as a UTC instant.
|
||||
*/
|
||||
private fun formatExdateStamp(local: JavaLocalDateTime, isAllDay: Boolean, zone: ZoneId): String =
|
||||
if (isAllDay) {
|
||||
local.toLocalDate().format(ALL_DAY_EXDATE)
|
||||
} else {
|
||||
local.atZone(zone).withZoneSameInstant(ZoneOffset.UTC).format(TIMED_EXDATE)
|
||||
}
|
||||
|
||||
/**
|
||||
* [stamp] as the wall clock it names in [zone] — the inverse of
|
||||
* [formatExdateStamp]. Null for anything Calendula cannot read, so a caller can
|
||||
* tell "not ours, leave it alone" from a value it may safely re-time.
|
||||
*
|
||||
* A timed exclusion is read both as the UTC instant Calendula writes and as a
|
||||
* floating stamp, which [listZone] then resolves. An all-day one is read both
|
||||
* bare (`yyyyMMdd`, what Calendula writes) and padded to a midnight stamp, in
|
||||
* either spelling: sync adapters are inconsistent about which they store — the
|
||||
* same forms [exportExDates] reshapes on the way out — and refusing one would
|
||||
* drop every exclusion of a foreign all-day series the moment it is re-timed.
|
||||
*/
|
||||
private fun parseExdateStamp(
|
||||
stamp: String,
|
||||
isAllDay: Boolean,
|
||||
zone: ZoneId,
|
||||
listZone: ZoneId,
|
||||
): JavaLocalDateTime? =
|
||||
if (isAllDay) {
|
||||
parseAllDayExdate(stamp)?.atStartOfDay()
|
||||
} else {
|
||||
parseTimedExdate(stamp, listZone)?.withZoneSameInstant(zone)?.toLocalDateTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* A timed EXDATE stamp as the instant it names: the trailing `Z` form resolved in
|
||||
* UTC, or the floating one resolved in [listZone].
|
||||
*/
|
||||
private fun parseTimedExdate(stamp: String, listZone: ZoneId): ZonedDateTime? =
|
||||
runCatching { JavaLocalDateTime.parse(stamp, TIMED_EXDATE).atZone(ZoneOffset.UTC) }.getOrNull()
|
||||
?: runCatching {
|
||||
JavaLocalDateTime.parse(stamp, FLOATING_EXDATE).atZone(listZone)
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* The calendar day an all-day EXDATE stamp names, bare or padded to a midnight
|
||||
* stamp. A padded stamp naming any other time of day is refused rather than
|
||||
* rounded: it is not a form an all-day series is written in, so it could be
|
||||
* naming something else entirely. Midnight is midnight in any zone, so the
|
||||
* padding's own spelling — trailing `Z` or floating — makes no difference.
|
||||
*/
|
||||
private fun parseAllDayExdate(stamp: String): JavaLocalDate? {
|
||||
runCatching { JavaLocalDate.parse(stamp, ALL_DAY_EXDATE) }.getOrNull()?.let { return it }
|
||||
val padded = parsePaddedExdate(stamp) ?: return null
|
||||
return padded.toLocalDate().takeIf { padded.toLocalTime() == JavaLocalTime.MIDNIGHT }
|
||||
}
|
||||
|
||||
/** An `uuuuMMddTHHmmss` stamp, with or without the trailing `Z`, as its own wall clock. */
|
||||
private fun parsePaddedExdate(stamp: String): JavaLocalDateTime? =
|
||||
runCatching { JavaLocalDateTime.parse(stamp, TIMED_EXDATE) }.getOrNull()
|
||||
?: runCatching { JavaLocalDateTime.parse(stamp, FLOATING_EXDATE) }.getOrNull()
|
||||
|
||||
/**
|
||||
* [stamp] in the form Calendula writes, so two spellings of the same exclusion
|
||||
* compare equal. Null for a stamp in no form it reads.
|
||||
*/
|
||||
internal fun canonicalExdateStamp(
|
||||
stamp: String,
|
||||
isAllDay: Boolean,
|
||||
listZone: ZoneId,
|
||||
): String? = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC, listZone)
|
||||
?.let { formatExdateStamp(it, isAllDay, ZoneOffset.UTC) }
|
||||
|
||||
/** `VALUE=DATE` EXDATE form, for an all-day series. */
|
||||
private val ALL_DAY_EXDATE: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)
|
||||
|
||||
/** UTC date-time EXDATE form, for a timed series. */
|
||||
private val TIMED_EXDATE: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss'Z'").withResolverStyle(ResolverStyle.STRICT)
|
||||
|
||||
/** The same form without the `Z`, wall clock in the list's zone rather than UTC. */
|
||||
private val FLOATING_EXDATE: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss").withResolverStyle(ResolverStyle.STRICT)
|
||||
|
||||
/**
|
||||
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
||||
@@ -229,3 +826,73 @@ internal fun AccessLevel.toProviderValue(): Int = when (this) {
|
||||
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
|
||||
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Events` row for a `.ics` event being imported into [calendarId].
|
||||
*
|
||||
* A recurring row carries RRULE + DURATION (and any EXDATE) with no DTEND; a
|
||||
* one-off carries DTEND. All-day rows live at UTC midnights, exactly as the
|
||||
* parser hands them over.
|
||||
*
|
||||
* [palette] is the target account's published event colours. A calendar that
|
||||
* publishes one rejects a raw `EVENT_COLOR`, so an imported colour — which is
|
||||
* an arbitrary ARGB from a foreign app — is snapped to the nearest key it does
|
||||
* accept; accounts with no palette take the raw value (see [eventColorColumns]).
|
||||
*/
|
||||
internal fun buildImportedEventValues(
|
||||
event: ParsedIcsEvent,
|
||||
calendarId: Long,
|
||||
uid: String,
|
||||
palette: List<EventColorOption>,
|
||||
): Map<String, Any?> = buildMap {
|
||||
val startMillis = event.start.toEpochMilliseconds()
|
||||
val endMillis = event.end.toEpochMilliseconds()
|
||||
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||
put(CalendarContract.Events.UID_2445, uid)
|
||||
put(CalendarContract.Events.TITLE, event.summary.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, startMillis)
|
||||
if (event.recurrenceRule == null) {
|
||||
put(CalendarContract.Events.DTEND, endMillis)
|
||||
} else {
|
||||
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
||||
put(
|
||||
CalendarContract.Events.DURATION,
|
||||
importDuration(startMillis, endMillis, event.isAllDay),
|
||||
)
|
||||
event.exDates.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EXDATE, it.joinToString(",")) }
|
||||
}
|
||||
// All-day rows live at UTC midnights (the file already encodes them so);
|
||||
// timed rows keep the event's own zone.
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
||||
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
||||
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
||||
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||
event.color?.let { color ->
|
||||
val key = palette.nearestTo(color)?.key
|
||||
putAll(eventColorColumns(colorKey = key, color = if (key == null) color else null))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider `DURATION` for an imported recurring row: whole days for an all-day
|
||||
* series, seconds otherwise. An all-day series is never shorter than a day —
|
||||
* the provider expands a zero-length one into no instances at all, so the
|
||||
* series would vanish (which is what a literal read of the Fossify family's
|
||||
* inclusive DTEND used to produce; see "Importing foreign .ics" in
|
||||
* docs/ARCHITECTURE.md).
|
||||
*/
|
||||
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
||||
val span = (endMillis - startMillis).coerceAtLeast(0)
|
||||
return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S"
|
||||
}
|
||||
|
||||
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
||||
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
|
||||
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
|
||||
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/**
|
||||
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
|
||||
@@ -11,14 +15,23 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
* [calendarName] the display name of its calendar (emitted as
|
||||
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
|
||||
* MapColumnReader.
|
||||
*
|
||||
* An all-day row's raw offset has the firing time of day encoded into it and is
|
||||
* normally *negative* (see [AllDayReminderEncoding]); written out literally it
|
||||
* would be dropped as a trigger that fires after the event, losing the reminder.
|
||||
* [allDayReminderTimeMinutes] and [zone] decode it back to the whole-day lead
|
||||
* time the file should carry, which an import re-encodes for its own device.
|
||||
*/
|
||||
internal fun ColumnReader.toIcsEvent(
|
||||
reminderMinutes: List<Int>,
|
||||
calendarName: String?,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
): IcsEvent {
|
||||
val eventId = getLong(EventExportProjection.IDX_ID)
|
||||
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
|
||||
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
|
||||
val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
|
||||
|
||||
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
|
||||
// so the writer can render DTEND. A missing/blank both means a zero-length event.
|
||||
@@ -39,16 +52,77 @@ internal fun ColumnReader.toIcsEvent(
|
||||
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
|
||||
start = dtStart.toKotlinInstantFromEpochMillis(),
|
||||
end = end.toKotlinInstantFromEpochMillis(),
|
||||
isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0,
|
||||
isAllDay = isAllDay,
|
||||
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
|
||||
?: "UTC",
|
||||
recurrenceRule = rrule,
|
||||
exDates = if (rrule == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
exportExDates(
|
||||
getString(EventExportProjection.IDX_EXDATE),
|
||||
isAllDay,
|
||||
getString(EventExportProjection.IDX_EVENT_TIMEZONE),
|
||||
)
|
||||
},
|
||||
location = getString(EventExportProjection.IDX_LOCATION),
|
||||
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
||||
reminderMinutes = reminderMinutes,
|
||||
reminderMinutes = if (isAllDay) {
|
||||
val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
reminderMinutes.map {
|
||||
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
|
||||
}
|
||||
} else {
|
||||
reminderMinutes
|
||||
},
|
||||
status = status,
|
||||
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
|
||||
calendarName = calendarName,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's `EXDATE` as the writer wants it: one stamp per entry, empties
|
||||
* dropped.
|
||||
*
|
||||
* An all-day exclusion names a calendar day, and sync adapters are inconsistent
|
||||
* about whether they write it as a bare `yyyyMMdd` or pad it to a midnight
|
||||
* stamp; the time part is dropped so the exported value is the day either way.
|
||||
* Calendula's own writes ([buildOccurrenceExdateValues]) are already bare.
|
||||
*
|
||||
* A zoned exclusion arrives in AOSP's `RecurrenceSet` form, `<zone>;<stamps>`,
|
||||
* where the prefix names the zone the whole list is wall clock in. [IcsWriter]
|
||||
* emits one bare `EXDATE:` line, and a parameter belongs before that colon, not
|
||||
* inside the value — passed on as-is it would make the line unparseable. The
|
||||
* prefix is therefore taken off and its stamps resolved to UTC instants
|
||||
* ([canonicalExdateStamp], the same reading the write path does), the form every
|
||||
* reader accepts.
|
||||
*
|
||||
* Which zone that resolution uses is [exdateListZone]'s call, the same one the
|
||||
* write path makes: the `TZID=` when this device's tz database knows the name,
|
||||
* else the row's own [eventTimezone]. A stamp is never emitted floating just
|
||||
* because the prefix named a zone we couldn't place — against a `DTSTART;TZID=…`
|
||||
* line every reader would resolve it in its own zone and the exclusion would stop
|
||||
* matching its occurrence.
|
||||
*/
|
||||
internal fun exportExDates(
|
||||
exdate: String?,
|
||||
isAllDay: Boolean,
|
||||
eventTimezone: String? = null,
|
||||
): List<String> {
|
||||
if (exdate.isNullOrBlank()) return emptyList()
|
||||
val zone = exdateListZone(exdate, eventTimezone)
|
||||
return exdate
|
||||
.withoutExdateParameters()
|
||||
.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.map { stamp ->
|
||||
if (isAllDay) {
|
||||
stamp.substringBefore('T')
|
||||
} else {
|
||||
canonicalExdateStamp(stamp, isAllDay = false, listZone = zone) ?: stamp
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventResponse
|
||||
|
||||
private const val TAG = "InstanceMapper"
|
||||
|
||||
@@ -18,8 +21,9 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
||||
return null
|
||||
}
|
||||
|
||||
val rawTitle = getString(InstanceProjection.IDX_TITLE)
|
||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||
// Left blank when the event has no title: the placeholder is a display
|
||||
// string, so it belongs to the UI layer where it can be localised (#321).
|
||||
val title = getString(InstanceProjection.IDX_TITLE).orEmpty()
|
||||
|
||||
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
|
||||
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
|
||||
@@ -37,5 +41,17 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
||||
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
|
||||
color = color,
|
||||
location = getString(InstanceProjection.IDX_LOCATION),
|
||||
response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELF_ATTENDEE_STATUS` as the calendar surfaces read it: a tentative "maybe"
|
||||
* counts as going, and everything that is not an open or refused invitation —
|
||||
* your own events included — falls through to [EventResponse.Going].
|
||||
*/
|
||||
internal fun mapEventResponse(raw: Int): EventResponse = when (raw) {
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED -> EventResponse.Declined
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> EventResponse.Invited
|
||||
else -> EventResponse.Going
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ internal object CalendarProjection {
|
||||
// uses to recognise its own managed calendars, independent of any
|
||||
// stored preference id (which a backup restore / data wipe can lose).
|
||||
MANAGED_MARKER_COLUMN,
|
||||
CalendarContract.Calendars.SYNC_EVENTS,
|
||||
)
|
||||
|
||||
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
||||
@@ -36,6 +37,7 @@ internal object CalendarProjection {
|
||||
const val IDX_ACCESS_LEVEL = 6
|
||||
const val IDX_DESCRIPTION = 7
|
||||
const val IDX_MANAGED_MARKER = 8
|
||||
const val IDX_SYNC_EVENTS = 9
|
||||
}
|
||||
|
||||
internal object InstanceProjection {
|
||||
@@ -50,6 +52,7 @@ internal object InstanceProjection {
|
||||
CalendarContract.Instances.EVENT_COLOR,
|
||||
CalendarContract.Instances.CALENDAR_COLOR,
|
||||
CalendarContract.Instances.EVENT_LOCATION,
|
||||
CalendarContract.Instances.SELF_ATTENDEE_STATUS,
|
||||
)
|
||||
|
||||
const val IDX_INSTANCE_ID = 0
|
||||
@@ -62,6 +65,7 @@ internal object InstanceProjection {
|
||||
const val IDX_EVENT_COLOR = 7
|
||||
const val IDX_CALENDAR_COLOR = 8
|
||||
const val IDX_LOCATION = 9
|
||||
const val IDX_SELF_ATTENDEE_STATUS = 10
|
||||
}
|
||||
|
||||
internal object EventDetailProjection {
|
||||
@@ -84,6 +88,12 @@ 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,
|
||||
// Non-null on a modified-occurrence exception row; "no RRULE" alone
|
||||
// can't tell an exception from a master (#68).
|
||||
CalendarContract.Events.ORIGINAL_ID,
|
||||
)
|
||||
|
||||
const val IDX_EVENT_ID = 0
|
||||
@@ -104,15 +114,19 @@ 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
|
||||
const val IDX_ORIGINAL_ID = 19
|
||||
}
|
||||
|
||||
/**
|
||||
* Master/one-off Events rows for a whole-calendar backup. Unlike
|
||||
* [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across
|
||||
* backups) and `DURATION` (recurring rows carry it instead of DTEND). Modified-
|
||||
* backups) and `DURATION` (recurring rows carry it instead of DTEND), plus
|
||||
* `EXDATE` so a series exports the occurrences deleted from it. Modified-
|
||||
* occurrence and cancelled-exception rows are filtered out by the query
|
||||
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides and EXDATEs aren't
|
||||
* exported yet — a documented v1 limit (import skips them too).
|
||||
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides aren't exported — a
|
||||
* documented v1 limit (import skips them too). EXDATE is unaffected by that
|
||||
* filter: it lives on the master row, not on an exception of its own.
|
||||
*/
|
||||
internal object EventExportProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
@@ -125,6 +139,7 @@ internal object EventExportProjection {
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events.EVENT_TIMEZONE,
|
||||
CalendarContract.Events.RRULE,
|
||||
CalendarContract.Events.EXDATE,
|
||||
CalendarContract.Events.EVENT_LOCATION,
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
CalendarContract.Events.STATUS,
|
||||
@@ -141,11 +156,12 @@ internal object EventExportProjection {
|
||||
const val IDX_ALL_DAY = 6
|
||||
const val IDX_EVENT_TIMEZONE = 7
|
||||
const val IDX_RRULE = 8
|
||||
const val IDX_LOCATION = 9
|
||||
const val IDX_DESCRIPTION = 10
|
||||
const val IDX_STATUS = 11
|
||||
const val IDX_AVAILABILITY = 12
|
||||
const val IDX_CALENDAR_ID = 13
|
||||
const val IDX_EXDATE = 9
|
||||
const val IDX_LOCATION = 10
|
||||
const val IDX_DESCRIPTION = 11
|
||||
const val IDX_STATUS = 12
|
||||
const val IDX_AVAILABILITY = 13
|
||||
const val IDX_CALENDAR_ID = 14
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,6 +186,9 @@ internal object SearchProjection {
|
||||
// display its nearest occurrence, not the series-start DTSTART.
|
||||
CalendarContract.Events.RRULE,
|
||||
CalendarContract.Events.RDATE,
|
||||
// Excerpted, not just filtered on: a hit has to show what it matched.
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||
)
|
||||
|
||||
const val IDX_ID = 0
|
||||
@@ -184,6 +203,88 @@ internal object SearchProjection {
|
||||
const val IDX_LOCATION = 9
|
||||
const val IDX_RRULE = 10
|
||||
const val IDX_RDATE = 11
|
||||
const val IDX_DESCRIPTION = 12
|
||||
const val IDX_SELF_ATTENDEE_STATUS = 13
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
@@ -211,8 +312,3 @@ internal object ReminderProjection {
|
||||
const val IDX_MINUTES = 0
|
||||
const val IDX_METHOD = 1
|
||||
}
|
||||
|
||||
internal object Fallbacks {
|
||||
const val UNNAMED_CALENDAR = "(Unbenannter Kalender)"
|
||||
const val UNTITLED_EVENT = "(Ohne Titel)"
|
||||
}
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
|
||||
/**
|
||||
* "Fire at whatever lead time this account defaults to" — a sentinel in the
|
||||
* `Reminders.MINUTES` column, not an offset, so nothing may do arithmetic on it.
|
||||
* `CalendarContract` never exposes what an account resolves it to, so Calendula
|
||||
* neither fires such a row ([withoutProviderDefaults]) nor decodes it
|
||||
* ([fromProviderAllDayMinutes]); the detail screen names it instead.
|
||||
*
|
||||
* It is the **only** negative that means this. A real all-day offset is often
|
||||
* negative too — the firing time of day is encoded into it — so a `< 0` test
|
||||
* would silence reminders that do fire.
|
||||
*/
|
||||
internal const val MINUTES_DEFAULT = CalendarContract.Reminders.MINUTES_DEFAULT
|
||||
|
||||
/**
|
||||
* Raw provider offsets with the account-default rows dropped, so the pure
|
||||
* planner ([de.jeanlucmakiola.calendula.domain.reminders.planReminders]) only
|
||||
* ever sees offsets it can resolve.
|
||||
*/
|
||||
internal fun Map<Long, List<Int>>.withoutProviderDefaults(): Map<Long, List<Int>> =
|
||||
mapValues { (_, minutes) -> minutes.filter { it != MINUTES_DEFAULT } }
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
|
||||
@@ -10,15 +11,17 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
* 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)
|
||||
if (dtStart < 0L) return null
|
||||
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 title = getString(SearchProjection.IDX_TITLE).orEmpty()
|
||||
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
|
||||
getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
||||
} else {
|
||||
@@ -36,5 +39,8 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
|
||||
color = color,
|
||||
location = getString(SearchProjection.IDX_LOCATION),
|
||||
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
||||
response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
+6
-6
@@ -4,7 +4,7 @@ import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
@@ -111,13 +111,13 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
* all-day override so new events keep matching. No-op if the calendar for
|
||||
* [type] doesn't exist yet.
|
||||
*/
|
||||
suspend fun applyReminders(type: SpecialDateType, override: CalendarReminderOverride) {
|
||||
suspend fun applyReminders(type: SpecialDateType, override: ReminderOverride) {
|
||||
val calendarId = prefs.specialDatesCalendars.first()[type] ?: return
|
||||
prefs.setCalendarAllDayReminderOverride(calendarId, override)
|
||||
val minutes = when (override) {
|
||||
CalendarReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
||||
CalendarReminderOverride.None -> emptyList()
|
||||
is CalendarReminderOverride.Minutes -> override.minutes
|
||||
ReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
||||
ReminderOverride.None -> emptyList()
|
||||
is ReminderOverride.Minutes -> override.minutes
|
||||
}
|
||||
calendars.applyManagedCalendarReminders(
|
||||
calendarId = calendarId,
|
||||
@@ -169,7 +169,7 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
|
||||
prefs.setCalendarAllDayReminderOverride(
|
||||
id,
|
||||
CalendarReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
||||
ReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
||||
)
|
||||
}
|
||||
return id
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.crash
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.Build
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Privacy-respecting crash capture (prod-readiness item 10). On an uncaught
|
||||
* exception it writes a self-contained report to the app's private storage and
|
||||
* then chains to the platform's default handler, so the process still dies
|
||||
* normally (and the OS shows its own "stopped" dialog). Nothing is uploaded —
|
||||
* the app holds no `INTERNET` permission. The user submits the report later,
|
||||
* by hand, as a Gitea issue (see the ui/crash surfaces).
|
||||
*
|
||||
* The report is built from a fixed [CrashContext] allowlist — app/Android/device
|
||||
* version, locale, time, and the stack trace — and **nothing else**: no device
|
||||
* identifiers, no account names, no calendar/event content, no logcat. The user
|
||||
* is always shown the full text before it leaves the device.
|
||||
*/
|
||||
object CrashReporter {
|
||||
|
||||
/**
|
||||
* Install the handler. Called first thing in `CalendulaApp.onCreate()` so it
|
||||
* also catches crashes during startup. The handler swallows nothing — it
|
||||
* persists, then delegates to the previously-registered handler.
|
||||
*/
|
||||
fun install(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
// Capturing must never mask the original crash, so guard every step.
|
||||
runCatching {
|
||||
val now = System.currentTimeMillis()
|
||||
writeReport(appContext, buildCrashReport(CrashContext.from(appContext), throwable, now))
|
||||
recordCrashTime(appContext, now)
|
||||
}
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
/** The persisted report from the last crash, or null if there is none. */
|
||||
fun pendingReport(context: Context): String? {
|
||||
val file = reportFile(context)
|
||||
return if (file.exists()) runCatching { file.readText() }.getOrNull()?.takeIf { it.isNotBlank() } else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to surface the report unprompted (on the next launch): a report
|
||||
* exists and the user hasn't already waved this one away. Settings reaches
|
||||
* the report via [pendingReport] regardless, so "Not now" only stops the
|
||||
* auto-prompt — it doesn't discard the report.
|
||||
*/
|
||||
fun shouldPrompt(context: Context): Boolean =
|
||||
reportFile(context).exists() && !dismissedFile(context).exists()
|
||||
|
||||
/** Stop auto-prompting for the current report without discarding it. */
|
||||
fun dismissPrompt(context: Context) {
|
||||
runCatching { dismissedFile(context).apply { parentFile?.mkdirs() }.writeText("") }
|
||||
}
|
||||
|
||||
/** Drop the persisted report once the user has reported it (or from Settings). */
|
||||
fun clearReport(context: Context) {
|
||||
runCatching { reportFile(context).delete() }
|
||||
runCatching { dismissedFile(context).delete() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app appears to be in a startup crash-loop: at least
|
||||
* [LOOP_THRESHOLD] crashes inside [LOOP_WINDOW_MS]. In that case the main UI
|
||||
* can't be trusted to start, so the caller routes straight to the standalone
|
||||
* report screen instead of re-entering the crashing graph.
|
||||
*/
|
||||
fun isCrashLoop(context: Context): Boolean {
|
||||
val times = readCrashTimes(context)
|
||||
if (times.size < LOOP_THRESHOLD) return false
|
||||
val recent = times.sortedDescending()
|
||||
return recent[0] - recent[LOOP_THRESHOLD - 1] <= LOOP_WINDOW_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the app as having started successfully, resetting the loop counter so
|
||||
* an ordinary single crash much later never trips loop detection. The
|
||||
* pending report itself is kept — only the timing trail is cleared.
|
||||
*/
|
||||
fun markHealthy(context: Context) {
|
||||
runCatching { timesFile(context).delete() }
|
||||
}
|
||||
|
||||
// --- persistence -------------------------------------------------------
|
||||
|
||||
private fun writeReport(context: Context, report: String) {
|
||||
val file = reportFile(context).apply { parentFile?.mkdirs() }
|
||||
file.writeText(report.take(MAX_REPORT_CHARS))
|
||||
// A fresh crash should prompt again, even if the previous one was waved away.
|
||||
runCatching { dismissedFile(context).delete() }
|
||||
}
|
||||
|
||||
private fun recordCrashTime(context: Context, nowMillis: Long) {
|
||||
val kept = (readCrashTimes(context) + nowMillis).takeLast(MAX_TIMES)
|
||||
timesFile(context).apply { parentFile?.mkdirs() }
|
||||
.writeText(kept.joinToString("\n"))
|
||||
}
|
||||
|
||||
private fun readCrashTimes(context: Context): List<Long> {
|
||||
val file = timesFile(context)
|
||||
if (!file.exists()) return emptyList()
|
||||
return runCatching { file.readLines().mapNotNull { it.trim().toLongOrNull() } }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun crashDir(context: Context) = File(context.filesDir, CRASH_DIR)
|
||||
private fun reportFile(context: Context) = File(crashDir(context), REPORT_FILE)
|
||||
private fun timesFile(context: Context) = File(crashDir(context), TIMES_FILE)
|
||||
private fun dismissedFile(context: Context) = File(crashDir(context), DISMISSED_FILE)
|
||||
|
||||
private const val CRASH_DIR = "crash"
|
||||
private const val REPORT_FILE = "last_crash.txt"
|
||||
private const val TIMES_FILE = "crash_times.txt"
|
||||
private const val DISMISSED_FILE = "dismissed"
|
||||
private const val MAX_TIMES = 5
|
||||
private const val MAX_REPORT_CHARS = 64 * 1024
|
||||
private const val LOOP_THRESHOLD = 2
|
||||
private const val LOOP_WINDOW_MS = 10_000L
|
||||
}
|
||||
|
||||
/**
|
||||
* The allowlist of non-personal facts that go into a crash report. Built from
|
||||
* [Build] and the app's own [PackageInfo]; deliberately holds no identifiers.
|
||||
*/
|
||||
data class CrashContext(
|
||||
val appVersionName: String,
|
||||
val appVersionCode: Long,
|
||||
val sdkInt: Int,
|
||||
val androidRelease: String,
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val locale: String,
|
||||
) {
|
||||
companion object {
|
||||
fun from(context: Context): CrashContext {
|
||||
val pkg = runCatching {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
}.getOrNull()
|
||||
return CrashContext(
|
||||
appVersionName = pkg?.versionName ?: "?",
|
||||
appVersionCode = pkg?.let { PackageInfoCompat.getLongVersionCode(it) } ?: 0L,
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
androidRelease = Build.VERSION.RELEASE ?: "?",
|
||||
manufacturer = Build.MANUFACTURER ?: "?",
|
||||
model = Build.MODEL ?: "?",
|
||||
locale = Locale.getDefault().toLanguageTag(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a crash report from the [ctx] allowlist, the [throwable]'s full stack
|
||||
* trace, and the crash [nowMillis]. Pure (no Android, no I/O) so it is unit
|
||||
* tested. The leading marker doubles as the file's sanity check in
|
||||
* [CrashReporter.pendingReport].
|
||||
*/
|
||||
fun buildCrashReport(ctx: CrashContext, throwable: Throwable, nowMillis: Long): String {
|
||||
val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
|
||||
val time = runCatching {
|
||||
Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()).format(TIME_FORMAT)
|
||||
}.getOrDefault(nowMillis.toString())
|
||||
return buildString {
|
||||
appendLine("Calendula crash report")
|
||||
appendLine("App version: ${ctx.appVersionName} (${ctx.appVersionCode})")
|
||||
appendLine("Android: ${ctx.androidRelease} (API ${ctx.sdkInt})")
|
||||
appendLine("Device: ${ctx.manufacturer} ${ctx.model}")
|
||||
appendLine("Locale: ${ctx.locale}")
|
||||
appendLine("Time: $time")
|
||||
appendLine()
|
||||
appendLine("Stack trace:")
|
||||
append(trace)
|
||||
}
|
||||
}
|
||||
|
||||
private val TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
@@ -18,8 +18,8 @@ import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataS
|
||||
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import javax.inject.Singleton
|
||||
@@ -46,9 +46,9 @@ abstract class DataBindModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindReminderAlertStore(
|
||||
impl: AndroidReminderAlertStore,
|
||||
): ReminderAlertStore
|
||||
abstract fun bindReminderInstanceSource(
|
||||
impl: ProviderReminderInstanceSource,
|
||||
): ReminderInstanceSource
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,25 @@
|
||||
package de.jeanlucmakiola.calendula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* App-side preference for "calendars the user has hidden in this app",
|
||||
* separate from the system's per-calendar VISIBLE flag.
|
||||
* App-side calendar preferences. [hiddenCalendarIds] is the drawer's filter
|
||||
* sheet — a purely in-app declutter that deliberately does *not* suppress
|
||||
* reminders. Switching a calendar off entirely is the system's
|
||||
* `Calendars.VISIBLE` flag, written straight to the provider (#75);
|
||||
* [pendingDisabledCalendarIds] only holds those switch-offs the app has not been
|
||||
* allowed to write yet.
|
||||
*
|
||||
* Persisted as a comma-separated string of Long ids; non-numeric tokens are
|
||||
* silently dropped (defensive — see CalendarPrefsTest).
|
||||
@@ -22,46 +29,56 @@ class CalendarPrefs @Inject constructor(
|
||||
private val store: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
val hiddenCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
||||
prefs[HIDDEN_IDS_KEY].orEmpty()
|
||||
.split(',')
|
||||
.mapNotNull { it.trim().toLongOrNull() }
|
||||
.toSet()
|
||||
}
|
||||
// Both id sets are deduped: the store is shared with SettingsPrefs, so any
|
||||
// unrelated write would otherwise re-emit an identical set.
|
||||
val hiddenCalendarIds: Flow<Set<Long>> = store.data
|
||||
.map { prefs -> prefs[HIDDEN_IDS_KEY].parseIds() }
|
||||
.distinctUntilChanged()
|
||||
|
||||
suspend fun setHiddenCalendarIds(ids: Set<Long>) {
|
||||
store.edit { prefs -> prefs.writeIds(HIDDEN_IDS_KEY, ids) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch-offs the provider does not know about yet, because writing
|
||||
* `Calendars.VISIBLE` needs `WRITE_CALENDAR` (#75). Also inherits the
|
||||
* retired app-local model's set, from the same key.
|
||||
*
|
||||
* Honoured as a display and reminder filter while non-empty, but not a
|
||||
* second visibility model: `CalendarVisibilityReconciler` drains it entry by
|
||||
* entry as soon as the app may write, and nothing adds to it while it may.
|
||||
*/
|
||||
val pendingDisabledCalendarIds: Flow<Set<Long>> = store.data
|
||||
.map { prefs -> prefs[DISABLED_IDS_KEY].parseIds() }
|
||||
.distinctUntilChanged()
|
||||
|
||||
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) =
|
||||
editPendingDisabled { it + ids }
|
||||
|
||||
/**
|
||||
* Drop [ids] from the pending set, one at a time as the reconciler flushes
|
||||
* them, so a run that fails part-way never re-applies what already landed.
|
||||
*/
|
||||
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) =
|
||||
editPendingDisabled { it - ids.toSet() }
|
||||
|
||||
private suspend fun editPendingDisabled(transform: (Set<Long>) -> Set<Long>) {
|
||||
store.edit { prefs ->
|
||||
if (ids.isEmpty()) {
|
||||
prefs.remove(HIDDEN_IDS_KEY)
|
||||
} else {
|
||||
prefs[HIDDEN_IDS_KEY] = ids.sorted().joinToString(",")
|
||||
}
|
||||
prefs.writeIds(DISABLED_IDS_KEY, transform(prefs[DISABLED_IDS_KEY].parseIds()))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* App-side preference for "calendars the user has disabled in this app" — a
|
||||
* heavier level than [hiddenCalendarIds]. A disabled calendar is removed from
|
||||
* every surface (drawer filter, event-form picker, import picker) and its
|
||||
* events never appear; it stays listed only in Settings → Calendars so it can
|
||||
* be re-enabled. Stored exactly like the hidden set; never touches the
|
||||
* system's VISIBLE/SYNC_EVENTS flags, so other calendar apps are unaffected.
|
||||
* Whether the one-time "visibility follows this device" notice is still
|
||||
* owed. Null until the reconciler has evaluated it (which needs the calendar
|
||||
* permission), false once it has been shown or was never needed.
|
||||
*/
|
||||
val disabledCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
||||
prefs[DISABLED_IDS_KEY].orEmpty()
|
||||
.split(',')
|
||||
.mapNotNull { it.trim().toLongOrNull() }
|
||||
.toSet()
|
||||
val visibilityNoticePending: Flow<Boolean?> = store.data.map { prefs ->
|
||||
prefs[VISIBILITY_NOTICE_KEY]
|
||||
}
|
||||
|
||||
suspend fun setDisabledCalendarIds(ids: Set<Long>) {
|
||||
store.edit { prefs ->
|
||||
if (ids.isEmpty()) {
|
||||
prefs.remove(DISABLED_IDS_KEY)
|
||||
} else {
|
||||
prefs[DISABLED_IDS_KEY] = ids.sorted().joinToString(",")
|
||||
}
|
||||
}
|
||||
suspend fun setVisibilityNoticePending(pending: Boolean) {
|
||||
store.edit { prefs -> prefs[VISIBILITY_NOTICE_KEY] = pending }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,6 +96,16 @@ class CalendarPrefs @Inject constructor(
|
||||
companion object {
|
||||
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
||||
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids")
|
||||
internal val VISIBILITY_NOTICE_KEY = booleanPreferencesKey("visibility_notice_pending")
|
||||
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
|
||||
}
|
||||
}
|
||||
|
||||
private fun String?.parseIds(): Set<Long> = orEmpty()
|
||||
.split(',')
|
||||
.mapNotNull { it.trim().toLongOrNull() }
|
||||
.toSet()
|
||||
|
||||
private fun MutablePreferences.writeIds(key: Preferences.Key<String>, ids: Set<Long>) {
|
||||
if (ids.isEmpty()) remove(key) else set(key, ids.sorted().joinToString(","))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.jeanlucmakiola.calendula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* How far reminder delivery has got — the one number replacing the provider's
|
||||
* `CalendarAlerts.STATE` (#75). A scan posts the reminders falling after this
|
||||
* watermark and up to now, then moves it to now, so a scan running twice cannot
|
||||
* post twice while a late one still catches up.
|
||||
*
|
||||
* Unset means "never scanned", not zero: the first scan after an install would
|
||||
* otherwise treat every reminder since the epoch as overdue.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderStatePrefs @Inject constructor(
|
||||
private val store: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
/** The watermark, or `null` before the first scan has ever run. */
|
||||
suspend fun lastScanMillis(): Long? = store.data.map { it[LAST_SCAN_KEY] }.first()
|
||||
|
||||
suspend fun setLastScanMillis(millis: Long) {
|
||||
store.edit { prefs -> prefs[LAST_SCAN_KEY] = millis }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val LAST_SCAN_KEY = longPreferencesKey("reminder_last_scan_millis")
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,34 @@ import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.normalizeReminders
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.common.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import java.time.ZoneId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import java.time.temporal.WeekFields
|
||||
import java.util.Locale
|
||||
@@ -72,6 +92,24 @@ fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
||||
WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved first day of the week as a hot [StateFlow] — the one shape every
|
||||
* screen that orders or lays out weekdays should use (the month grid, the agenda
|
||||
* "this week" range, the recurrence weekday toggles). Sharing it keeps the
|
||||
* initial-frame value identical everywhere; hand-rolled copies had drifted onto
|
||||
* different `initialValue`s, so two surfaces could disagree for a frame.
|
||||
*/
|
||||
fun SettingsPrefs.firstDayOfWeek(scope: CoroutineScope): StateFlow<DayOfWeek> =
|
||||
weekStart
|
||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
||||
.stateIn(
|
||||
scope = scope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
// Seed with the locale convention rather than a hardcoded weekday, so
|
||||
// the first frame is already right for everyone still on Auto.
|
||||
initialValue = WeekStartPref.Auto.resolveFirstDay(Locale.getDefault()),
|
||||
)
|
||||
|
||||
/**
|
||||
* Display settings (M4) persisted app-side: theme override, Material You
|
||||
* dynamic colour, and week start. Language is handled separately through
|
||||
@@ -105,6 +143,62 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether raw provider colours are softened toward theme-fitting pastels
|
||||
* before display (issue #36). Defaults to ON — the historical look, which
|
||||
* caps harsh sync colours and pins brightness so entries read on both
|
||||
* themes. Turning it off paints the calendar/event colour exactly as the
|
||||
* sync source (DAVx5/CalDAV) publishes it.
|
||||
*/
|
||||
val softenCalendarColors: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[SOFTEN_COLORS_KEY] ?: true
|
||||
}
|
||||
|
||||
suspend fun setSoftenCalendarColors(enabled: Boolean) {
|
||||
store.edit { it[SOFTEN_COLORS_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom-font tokens per Material typeface role (issue #19). Stored as opaque
|
||||
* strings — "system", "custom", or a bundled font's token — resolved to a
|
||||
* FontFamily at the theme layer; an unknown token degrades to the default.
|
||||
*/
|
||||
val brandFont: Flow<String> = store.data.map { prefs ->
|
||||
prefs[BRAND_FONT_KEY] ?: FONT_SYSTEM_TOKEN
|
||||
}
|
||||
|
||||
val plainFont: Flow<String> = store.data.map { prefs ->
|
||||
prefs[PLAIN_FONT_KEY] ?: FONT_SYSTEM_TOKEN
|
||||
}
|
||||
|
||||
suspend fun setBrandFont(token: String) {
|
||||
store.edit { it[BRAND_FONT_KEY] = token }
|
||||
}
|
||||
|
||||
suspend fun setPlainFont(token: String) {
|
||||
store.edit { it[PLAIN_FONT_KEY] = token }
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-role bump counter for the user-loaded custom font. Re-importing
|
||||
* overwrites the same file under the same "custom" token, so the token alone
|
||||
* can't signal the change; this stamp — carried into AppFontSettings — breaks
|
||||
* value equality so the resolved FontFamily (and its previews) refresh. A
|
||||
* missing key is 0 (existing installs); the first import bumps it to 1.
|
||||
*/
|
||||
val brandFontStamp: Flow<Int> = store.data.map { prefs -> prefs[BRAND_FONT_STAMP_KEY] ?: 0 }
|
||||
|
||||
val plainFontStamp: Flow<Int> = store.data.map { prefs -> prefs[PLAIN_FONT_STAMP_KEY] ?: 0 }
|
||||
|
||||
/** Bump [role]'s custom-font stamp after a re-import so equality-keyed caches refresh. */
|
||||
suspend fun bumpCustomFontStamp(role: FontRole) {
|
||||
val key = when (role) {
|
||||
FontRole.BRAND -> BRAND_FONT_STAMP_KEY
|
||||
FontRole.PLAIN -> PLAIN_FONT_STAMP_KEY
|
||||
}
|
||||
store.edit { it[key] = (it[key] ?: 0) + 1 }
|
||||
}
|
||||
|
||||
suspend fun setWeekStart(pref: WeekStartPref) {
|
||||
store.edit { it[WEEK_START_KEY] = pref.storageValue() }
|
||||
}
|
||||
@@ -119,14 +213,15 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the week/day timeline draws a faint separator line at each hour
|
||||
* (v2.11). Defaults to OFF — the historical clean look; users opt in.
|
||||
* Whether the week/day timeline seats each hour in its own cell (v2.11, a
|
||||
* separator line until v2.20). Defaults to OFF — the historical flat column;
|
||||
* users opt in. The stored key keeps its original name.
|
||||
*/
|
||||
val showHourLines: Flow<Boolean> = store.data.map { prefs ->
|
||||
val showHourGrid: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[SHOW_HOUR_LINES_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setShowHourLines(enabled: Boolean) {
|
||||
suspend fun setShowHourGrid(enabled: Boolean) {
|
||||
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
|
||||
}
|
||||
|
||||
@@ -155,6 +250,102 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the Month grid shows the calendar-week (ISO) number in a left
|
||||
* gutter (#25). Defaults to OFF — users opt in, since it narrows the day
|
||||
* cells slightly. The Week view shows its number unconditionally.
|
||||
*/
|
||||
val showWeekNumbers: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[SHOW_WEEK_NUMBERS_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setShowWeekNumbers(enabled: Boolean) {
|
||||
store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How the Month view lays itself out (#38, #53). Defaults to [MonthViewStyle.Paged]
|
||||
* — the historical behaviour, so existing installs see no change until they opt in.
|
||||
*/
|
||||
val monthViewStyle: Flow<MonthViewStyle> = store.data.map { prefs ->
|
||||
prefs[MONTH_VIEW_STYLE_KEY].toEnum(MonthViewStyle.Paged)
|
||||
}
|
||||
|
||||
suspend fun setMonthViewStyle(style: MonthViewStyle) {
|
||||
store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* How tall an hour is drawn in the week and day timelines (#56). Defaults to
|
||||
* [TimelineScale.Regular] — the historical 56dp scale. Holds either a preset
|
||||
* or the height a pinch on the timeline settled at.
|
||||
*/
|
||||
val timelineScale: Flow<TimelineScale> = store.data.map { prefs ->
|
||||
parseTimelineScale(prefs[TIMELINE_SCALE_KEY])
|
||||
}
|
||||
|
||||
suspend fun setTimelineScale(scale: TimelineScale) {
|
||||
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
|
||||
}
|
||||
|
||||
/** How many days the multi-day view shows side by side (#193). */
|
||||
val multiDayCount: Flow<Int> = store.data.map { prefs ->
|
||||
(prefs[MULTI_DAY_COUNT_KEY] ?: DEFAULT_MULTI_DAY_COUNT).coerceIn(MULTI_DAY_COUNTS)
|
||||
}
|
||||
|
||||
suspend fun setMultiDayCount(count: Int) {
|
||||
store.edit { it[MULTI_DAY_COUNT_KEY] = count.coerceIn(MULTI_DAY_COUNTS) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The hours the day, week and multi-day timelines fit to the screen when they
|
||||
* open (#322). Off by default; the hours survive switching it off.
|
||||
*/
|
||||
val timelineHours: Flow<TimelineHours> = store.data.map { prefs ->
|
||||
TimelineHours.of(
|
||||
enabled = prefs[TIMELINE_HOURS_ENABLED_KEY] ?: false,
|
||||
startHour = prefs[TIMELINE_HOURS_START_KEY] ?: TimelineHours.DEFAULT_START_HOUR,
|
||||
endHour = prefs[TIMELINE_HOURS_END_KEY] ?: TimelineHours.DEFAULT_END_HOUR,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setTimelineHours(hours: TimelineHours) {
|
||||
val valid = TimelineHours.of(hours.enabled, hours.startHour, hours.endHour)
|
||||
store.edit {
|
||||
it[TIMELINE_HOURS_ENABLED_KEY] = valid.enabled
|
||||
it[TIMELINE_HOURS_START_KEY] = valid.startHour
|
||||
it[TIMELINE_HOURS_END_KEY] = valid.endHour
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
||||
* historical layout, where it's an extended FAB that fades in above the "+"
|
||||
* only while the view is off today. Turning it ON moves it to a persistent
|
||||
* icon button in each calendar view's top bar (always shown, on today or
|
||||
* not) and drops the FAB pill.
|
||||
*/
|
||||
val todayButtonInToolbar: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[TODAY_BUTTON_IN_TOOLBAR_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setTodayButtonInToolbar(enabled: Boolean) {
|
||||
store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether events can be dragged to another slot in the calendar views (#68,
|
||||
* #173). Defaults to ON. Off means no block registers a drag gesture at all;
|
||||
* rescheduling then goes through the edit form.
|
||||
*/
|
||||
val dragToReschedule: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[DRAG_TO_RESCHEDULE_KEY] ?: true
|
||||
}
|
||||
|
||||
suspend fun setDragToReschedule(enabled: Boolean) {
|
||||
store.edit { it[DRAG_TO_RESCHEDULE_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||
@@ -177,6 +368,23 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The size step the agenda widget draws its text at (#51). Defaults to
|
||||
* [WidgetSize.SMALL], which reproduces its original metrics, so an existing
|
||||
* widget is unchanged until its owner turns the size up.
|
||||
*
|
||||
* This replaced deriving a size tier from the widget's measured size, which
|
||||
* the launcher does not report reliably. The month widget takes no size
|
||||
* setting — it divides the width it is given by seven (#103).
|
||||
*/
|
||||
val widgetSize: Flow<WidgetSize> = store.data.map { prefs ->
|
||||
prefs[WIDGET_SIZE_KEY].toEnum(WidgetSize.SMALL)
|
||||
}
|
||||
|
||||
suspend fun setWidgetSize(size: WidgetSize) {
|
||||
store.edit { it[WIDGET_SIZE_KEY] = size.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agenda shows its top range bar — the "showing …" header and
|
||||
* the session range switcher (v2.11). Default ON.
|
||||
@@ -189,6 +397,20 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agenda (both the in-app screen and the home-screen widget)
|
||||
* always anchors today at the top with a "nothing left today" placeholder,
|
||||
* even once today has no remaining events (issue #35). Default ON — makes
|
||||
* today's events easy to tell apart from a future day's at a glance.
|
||||
*/
|
||||
val agendaShowToday: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[AGENDA_SHOW_TODAY_KEY] ?: true
|
||||
}
|
||||
|
||||
suspend fun setAgendaShowToday(enabled: Boolean) {
|
||||
store.edit { it[AGENDA_SHOW_TODAY_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
|
||||
* the historical hard-coded startup view — so existing users see no change
|
||||
@@ -203,6 +425,47 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[DEFAULT_VIEW_KEY] = view.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick-switch button customisation (#24): the ordered full view list plus
|
||||
* which views are enabled in the cycle. Stored comma-joined by enum name, a
|
||||
* "!" prefix marking a disabled view (e.g. "Month,Week,!Day,Agenda"). Missing
|
||||
* views are appended enabled and unknown names dropped, so a future view
|
||||
* defaults into the cycle. An absent key means [QuickSwitchConfig.Default].
|
||||
*/
|
||||
val quickSwitchConfig: Flow<QuickSwitchConfig> = store.data.map { prefs ->
|
||||
parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
|
||||
}
|
||||
|
||||
suspend fun setQuickSwitchConfig(config: QuickSwitchConfig) {
|
||||
store.edit { it[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(config) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic read-modify-write of the quick-switch config: [transform] sees the
|
||||
* value currently stored, parsed inside the edit block, not the async-echoed
|
||||
* UI snapshot. Two rapid intents (a toggle then a drag) therefore compose
|
||||
* instead of each re-serialising a stale copy of the other field over it.
|
||||
*/
|
||||
suspend fun updateQuickSwitch(transform: (QuickSwitchConfig) -> QuickSwitchConfig) {
|
||||
store.edit { prefs ->
|
||||
val current = parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
|
||||
prefs[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(transform(current))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation-drawer view order (#24). Comma-joined enum names; missing views
|
||||
* are appended in default order and unknown names dropped. Absent key means
|
||||
* [IMPLEMENTED_VIEWS] (the historical fixed order).
|
||||
*/
|
||||
val drawerViewOrder: Flow<List<CalendarView>> = store.data.map { prefs ->
|
||||
parseViewOrder(prefs[DRAWER_VIEW_ORDER_KEY])
|
||||
}
|
||||
|
||||
suspend fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||
store.edit { it[DRAWER_VIEW_ORDER_KEY] = order.joinToString(",") { view -> view.name } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional event-form fields shown by default (the rest hide behind
|
||||
* "more fields"). Stored comma-joined by enum name: an absent key means
|
||||
@@ -221,6 +484,26 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zones the user picked recently, most recent first — the timezone picker's
|
||||
* default list, since scrolling ~600 zones to re-find the two you actually
|
||||
* use is the whole problem. Stored comma-joined (IANA ids never contain a
|
||||
* comma); unparseable ids are dropped on read, so a zone the tz database
|
||||
* later retires can't wedge the list.
|
||||
*/
|
||||
val recentTimeZones: Flow<List<String>> = store.data.map { prefs ->
|
||||
parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY])
|
||||
}
|
||||
|
||||
suspend fun addRecentTimeZone(zoneId: String) {
|
||||
store.edit { prefs ->
|
||||
val updated = (listOf(zoneId) + parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY]))
|
||||
.distinct()
|
||||
.take(MAX_RECENT_TIME_ZONES)
|
||||
prefs[RECENT_TIMEZONES_KEY] = updated.joinToString(",")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opening the new-event form focuses the title field and raises the
|
||||
* keyboard straight away (issue #10). Default ON — a new event almost always
|
||||
@@ -235,6 +518,46 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a new **timed** event lasts, in minutes (#54). Defaults to
|
||||
* [DEFAULT_EVENT_DURATION] — the historical fixed hour. Per-calendar
|
||||
* overrides in [perCalendarEventDuration] take precedence; all-day events are
|
||||
* date-anchored and ignore it. Resolve with [resolveDefaultEventDuration].
|
||||
*/
|
||||
val defaultEventDurationMinutes: Flow<Int> = store.data.map { prefs ->
|
||||
(prefs[DEFAULT_EVENT_DURATION_KEY] ?: DEFAULT_EVENT_DURATION)
|
||||
.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
||||
}
|
||||
|
||||
suspend fun setDefaultEventDurationMinutes(minutes: Int) {
|
||||
store.edit {
|
||||
it[DEFAULT_EVENT_DURATION_KEY] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-calendar overrides of [defaultEventDurationMinutes], keyed by calendar
|
||||
* id: a calendar **present** in the map gives its new events that length, one
|
||||
* **absent** inherits the global default (there is no "no duration", so this
|
||||
* needs no `none` sentinel). Serialised as `id=minutes;id=minutes`.
|
||||
*/
|
||||
val perCalendarEventDuration: Flow<Map<Long, Int>> = store.data.map { prefs ->
|
||||
parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY])
|
||||
}
|
||||
|
||||
/** [minutes] null drops the override, so the calendar inherits the global default. */
|
||||
suspend fun setCalendarEventDuration(calendarId: Long, minutes: Int?) {
|
||||
store.edit { prefs ->
|
||||
val current = parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY]).toMutableMap()
|
||||
if (minutes == null) {
|
||||
current.remove(calendarId)
|
||||
} else {
|
||||
current[calendarId] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
||||
}
|
||||
prefs[CALENDAR_EVENT_DURATION_KEY] = current.toStoredDurations()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
||||
* for users whose only calendar app this is, reminders are essential; the
|
||||
@@ -272,8 +595,105 @@ class SettingsPrefs @Inject constructor(
|
||||
prefs[REMINDER_ONBOARDING_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setReminderOnboardingDone() {
|
||||
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
||||
/** Cleared, not just set: the wizard's back button re-asks a step (#163). */
|
||||
suspend fun setReminderOnboardingDone(done: Boolean = true) {
|
||||
store.edit { it[REMINDER_ONBOARDING_KEY] = done }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this install is a fresh one still owing the wizard's optional
|
||||
* steps (#163). Armed on any launch that holds the calendar permission and
|
||||
* has not finished the reminder step, and cleared again by
|
||||
* [finishOnboardingWizard], so an existing user who revokes and re-grants
|
||||
* the permission isn't re-onboarded.
|
||||
*/
|
||||
val onboardingWizardArmed: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_WIZARD_ARMED_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun armOnboardingWizard() {
|
||||
store.edit { prefs ->
|
||||
if (prefs[REMINDER_ONBOARDING_KEY] != true) prefs[ONBOARDING_WIZARD_ARMED_KEY] = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the wizard's calendar step has been answered (or skipped). */
|
||||
val onboardingCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_CALENDARS_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingCalendarsDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_CALENDARS_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's hidden-calendars step has been answered (or skipped). */
|
||||
val onboardingHiddenCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_HIDDEN_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingHiddenCalendarsDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_HIDDEN_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's backup step has been answered (or skipped). */
|
||||
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingBackupDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_BACKUP_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's default-view step has been answered (or skipped). */
|
||||
val onboardingViewDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_VIEW_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingViewDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_VIEW_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's month-style step has been answered (or skipped). */
|
||||
val onboardingMonthStyleDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_MONTH_STYLE_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingMonthStyleDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_MONTH_STYLE_KEY] = done }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the wizard's calendar-visibility notice has been read. Separate
|
||||
* from [CalendarPrefs.visibilityNoticePending], which says whether this
|
||||
* install has anything to announce at all.
|
||||
*/
|
||||
val onboardingVisibilityDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_VISIBILITY_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingVisibilityDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_VISIBILITY_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's closing screen has been seen. */
|
||||
val onboardingDoneShown: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_DONE_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingDoneShown(shown: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_DONE_KEY] = shown }
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the wizard for good: the closing screen has been seen and the
|
||||
* install stops counting as fresh, so a later revoke-and-re-grant is only
|
||||
* the permission screen. One edit, so the plan can't see it half-closed.
|
||||
*/
|
||||
suspend fun finishOnboardingWizard() {
|
||||
store.edit { prefs ->
|
||||
prefs[ONBOARDING_DONE_KEY] = true
|
||||
prefs[ONBOARDING_WIZARD_ARMED_KEY] = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -350,14 +770,14 @@ class SettingsPrefs @Inject constructor(
|
||||
* (All-day events ignore this and use [defaultAllDayReminderMinutes].)
|
||||
*/
|
||||
val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||
reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||
}
|
||||
|
||||
suspend fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||
suspend fun setCalendarReminderOverride(calendarId: Long, override: ReminderOverride) {
|
||||
store.edit { prefs ->
|
||||
val current = parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||
current.applyOverride(calendarId, override)
|
||||
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
||||
val current = reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||
current.applyReminderOverride(calendarId, override)
|
||||
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,18 +787,18 @@ class SettingsPrefs @Inject constructor(
|
||||
* inherit the global all-day default; present null = no reminder).
|
||||
*/
|
||||
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||
reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||
}
|
||||
|
||||
suspend fun setCalendarAllDayReminderOverride(
|
||||
calendarId: Long,
|
||||
override: CalendarReminderOverride,
|
||||
override: ReminderOverride,
|
||||
) {
|
||||
store.edit { prefs ->
|
||||
val current =
|
||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||
current.applyOverride(calendarId, override)
|
||||
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
||||
reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||
current.applyReminderOverride(calendarId, override)
|
||||
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,6 +937,13 @@ class SettingsPrefs @Inject constructor(
|
||||
private fun titleTemplateKey(type: SpecialDateType) =
|
||||
stringPreferencesKey("special_dates_title_${type.name}")
|
||||
|
||||
private fun parseRecentTimeZones(stored: String?): List<String> =
|
||||
stored?.split(',').orEmpty()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && runCatching { ZoneId.of(it) }.isSuccess }
|
||||
.distinct()
|
||||
.take(MAX_RECENT_TIME_ZONES)
|
||||
|
||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||
null -> DEFAULT_FORM_FIELDS
|
||||
else -> stored.split(',')
|
||||
@@ -524,22 +951,102 @@ class SettingsPrefs @Inject constructor(
|
||||
.toSet()
|
||||
}
|
||||
|
||||
/** Parse a plain comma-joined view order, completed to every implemented view. */
|
||||
private fun parseViewOrder(stored: String?): List<CalendarView> =
|
||||
completeViewOrder(
|
||||
stored?.split(',').orEmpty()
|
||||
.mapNotNull { name -> CalendarView.entries.firstOrNull { it.name == name.trim() } },
|
||||
)
|
||||
|
||||
/** Parse the quick-switch config; "!"-prefixed names are disabled. */
|
||||
private fun parseQuickSwitch(stored: String?): QuickSwitchConfig {
|
||||
if (stored == null) return QuickSwitchConfig.Default
|
||||
val parsed = stored.split(',').mapNotNull { raw ->
|
||||
val token = raw.trim()
|
||||
val disabled = token.startsWith("!")
|
||||
val name = if (disabled) token.drop(1) else token
|
||||
CalendarView.entries.firstOrNull { it.name == name }?.let { it to disabled }
|
||||
}
|
||||
val order = completeViewOrder(parsed.map { it.first })
|
||||
// Only views explicitly stored disabled are excluded; anything appended
|
||||
// (a view added in a later release) defaults into the cycle.
|
||||
val disabled = parsed.filter { it.second }.map { it.first }.toSet()
|
||||
return QuickSwitchConfig(order, order.filterNot { it in disabled }.toSet())
|
||||
}
|
||||
|
||||
private fun serializeQuickSwitch(config: QuickSwitchConfig): String =
|
||||
completeViewOrder(config.order).joinToString(",") { view ->
|
||||
if (view in config.enabled) view.name else "!${view.name}"
|
||||
}
|
||||
|
||||
/** Keep stored order; insert the new multi-day view after Week on upgrades. */
|
||||
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
|
||||
val ordered = seen.distinct().toMutableList()
|
||||
IMPLEMENTED_VIEWS.forEach { view ->
|
||||
if (view !in ordered) {
|
||||
val index = if (view == CalendarView.MultiDay && CalendarView.Week in ordered) {
|
||||
ordered.indexOf(CalendarView.Week) + 1
|
||||
} else {
|
||||
ordered.size
|
||||
}
|
||||
ordered.add(index, view)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||
internal val SOFTEN_COLORS_KEY = booleanPreferencesKey("soften_calendar_colors")
|
||||
internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font")
|
||||
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
|
||||
internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp")
|
||||
internal val PLAIN_FONT_STAMP_KEY = intPreferencesKey("plain_font_stamp")
|
||||
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||
internal val WIDGET_SIZE_KEY = stringPreferencesKey("widget_size")
|
||||
internal val AGENDA_SHOW_TODAY_KEY =
|
||||
booleanPreferencesKey("agenda_show_today")
|
||||
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
||||
internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style")
|
||||
internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale")
|
||||
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
||||
internal val DRAG_TO_RESCHEDULE_KEY = booleanPreferencesKey("drag_to_reschedule")
|
||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||
internal val DEFAULT_EVENT_DURATION_KEY = intPreferencesKey("default_event_duration_minutes")
|
||||
internal val CALENDAR_EVENT_DURATION_KEY =
|
||||
stringPreferencesKey("per_calendar_event_duration")
|
||||
|
||||
/** A new timed event's length until the user changes it: one hour. */
|
||||
const val DEFAULT_EVENT_DURATION = 60
|
||||
internal const val MIN_EVENT_DURATION = 1
|
||||
/** A day — past that the form is describing a multi-day event, not a default. */
|
||||
internal const val MAX_EVENT_DURATION = 1_440
|
||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
|
||||
internal val ONBOARDING_CALENDARS_KEY =
|
||||
booleanPreferencesKey("onboarding_calendars_done")
|
||||
internal val ONBOARDING_HIDDEN_KEY =
|
||||
booleanPreferencesKey("onboarding_hidden_calendars_done")
|
||||
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
|
||||
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
||||
internal val ONBOARDING_MONTH_STYLE_KEY =
|
||||
booleanPreferencesKey("onboarding_month_style_done")
|
||||
internal val ONBOARDING_VISIBILITY_KEY =
|
||||
booleanPreferencesKey("onboarding_visibility_done")
|
||||
internal val ONBOARDING_DONE_KEY = booleanPreferencesKey("onboarding_done_shown")
|
||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||
booleanPreferencesKey("allow_color_unsupported_calendars")
|
||||
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")
|
||||
@@ -551,14 +1058,24 @@ class SettingsPrefs @Inject constructor(
|
||||
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
|
||||
internal val MULTI_DAY_COUNT_KEY = intPreferencesKey("multi_day_count")
|
||||
internal val TIMELINE_HOURS_ENABLED_KEY = booleanPreferencesKey("timeline_hours_enabled")
|
||||
internal val TIMELINE_HOURS_START_KEY = intPreferencesKey("timeline_hours_start")
|
||||
internal val TIMELINE_HOURS_END_KEY = intPreferencesKey("timeline_hours_end")
|
||||
/** Default snooze delay for the notification "Snooze" action. */
|
||||
const val DEFAULT_SNOOZE_MINUTES = 10
|
||||
const val DEFAULT_MULTI_DAY_COUNT = 3
|
||||
val MULTI_DAY_COUNTS = 3..6
|
||||
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
||||
stringPreferencesKey("per_calendar_reminder_override")
|
||||
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
||||
stringPreferencesKey("per_calendar_allday_reminder_override")
|
||||
internal val DEFAULT_FORM_FIELDS =
|
||||
setOf(EventFormField.Location, EventFormField.Description)
|
||||
internal val RECENT_TIMEZONES_KEY = stringPreferencesKey("recent_time_zones")
|
||||
|
||||
/** Enough to cover the zones a user actually recurs to, without a wall of rows. */
|
||||
internal const val MAX_RECENT_TIME_ZONES = 5
|
||||
internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
|
||||
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
|
||||
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
|
||||
@@ -601,28 +1118,6 @@ data class BackupStatus(
|
||||
val consecutiveFailures: Int,
|
||||
)
|
||||
|
||||
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
|
||||
sealed interface CalendarReminderOverride {
|
||||
/** No override — the calendar uses the global default. */
|
||||
data object Inherit : CalendarReminderOverride
|
||||
/** Explicit "no reminder" for this calendar, regardless of the global default. */
|
||||
data object None : CalendarReminderOverride
|
||||
/** Specific lead times in minutes before the event start (non-empty). */
|
||||
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored override for [calendarId] in an override map, as a
|
||||
* [CalendarReminderOverride] (absent → [CalendarReminderOverride.Inherit],
|
||||
* empty → [CalendarReminderOverride.None]) — the inverse of how
|
||||
* [SettingsPrefs.setCalendarReminderOverride] stores a choice.
|
||||
*/
|
||||
fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
||||
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
||||
getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
|
||||
else -> CalendarReminderOverride.Minutes(getValue(calendarId))
|
||||
}
|
||||
|
||||
/**
|
||||
* The lead times to prefill on a new event: the matching per-calendar override
|
||||
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||
@@ -648,19 +1143,15 @@ fun resolveDefaultReminder(
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the
|
||||
* key; [None] and an empty [Minutes] both store the empty list).
|
||||
* The length a new timed event on [calendarId] opens with: that calendar's
|
||||
* override if it has one, otherwise the [global] default. Pure so it can be
|
||||
* unit-tested.
|
||||
*/
|
||||
private fun MutableMap<Long, List<Int>>.applyOverride(
|
||||
calendarId: Long,
|
||||
override: CalendarReminderOverride,
|
||||
) {
|
||||
when (override) {
|
||||
CalendarReminderOverride.Inherit -> remove(calendarId)
|
||||
CalendarReminderOverride.None -> put(calendarId, emptyList())
|
||||
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders())
|
||||
}
|
||||
}
|
||||
fun resolveDefaultEventDuration(
|
||||
global: Int,
|
||||
overrides: Map<Long, Int>,
|
||||
calendarId: Long?,
|
||||
): Int = calendarId?.let { overrides[it] } ?: global
|
||||
|
||||
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
||||
private const val WEEK_START_AUTO = "AUTO"
|
||||
@@ -686,8 +1177,14 @@ private const val ENTRY_SEP = ";"
|
||||
private const val KEY_VALUE_SEP = "="
|
||||
private const val LIST_SEP = ","
|
||||
|
||||
/** Distinct, ascending lead times — the canonical form stored and resolved. */
|
||||
private fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
|
||||
/**
|
||||
* The per-calendar override map codec, in Calendula's stored dialect
|
||||
* (`id=minutes` entries joined by `;`, minutes comma-joined, `none` for an
|
||||
* explicit no-reminder). The model and codec live in floret-kit; the dialect
|
||||
* (fixed at release) stays here.
|
||||
*/
|
||||
private val reminderOverrideCodec =
|
||||
ReminderOverrideCodec(entrySep = ENTRY_SEP, keyValueSep = KEY_VALUE_SEP, listSep = LIST_SEP, noneToken = NONE)
|
||||
|
||||
/**
|
||||
* Parse a stored reminder value into lead times. `null`/empty/"none" → empty
|
||||
@@ -703,17 +1200,27 @@ private fun String?.toReminderList(): List<Int> = when {
|
||||
private fun List<Int>.toStoredReminders(): String =
|
||||
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
||||
|
||||
private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
|
||||
if (stored.isNullOrBlank()) return emptyMap()
|
||||
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
||||
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
||||
id to parts[1].toReminderList()
|
||||
/**
|
||||
* Parse the per-calendar duration map (`id=minutes` entries joined by `;`).
|
||||
* Malformed entries and out-of-range lengths are dropped, so a garbled value
|
||||
* degrades to "inherits the global default" instead of throwing.
|
||||
*/
|
||||
private fun parseDurationOverrides(stored: String?): Map<Long, Int> =
|
||||
stored?.split(ENTRY_SEP).orEmpty().mapNotNull { entry ->
|
||||
val parts = entry.split(KEY_VALUE_SEP)
|
||||
if (parts.size != 2) return@mapNotNull null
|
||||
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
|
||||
val minutes = parts[1].trim().toIntOrNull()
|
||||
?.takeIf { it in SettingsPrefs.MIN_EVENT_DURATION..SettingsPrefs.MAX_EVENT_DURATION }
|
||||
?: return@mapNotNull null
|
||||
calendarId to minutes
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String =
|
||||
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" }
|
||||
private fun Map<Long, Int>.toStoredDurations(): String =
|
||||
entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, minutes) ->
|
||||
"$id$KEY_VALUE_SEP$minutes"
|
||||
}
|
||||
|
||||
|
||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.Manifest
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.CalendarContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The due alerts that should actually surface as notifications: everything
|
||||
* except alerts whose calendar the user has disabled in-app (mirroring the
|
||||
* event filtering in CalendarRepositoryImpl). The caller still marks the full
|
||||
* due set fired, so suppressed alerts are not re-broadcast by the provider.
|
||||
*/
|
||||
internal fun postableAlerts(
|
||||
due: List<ReminderAlert>,
|
||||
disabledCalendarIds: Set<Long>,
|
||||
): List<ReminderAlert> = due.filterNot { it.calendarId in disabledCalendarIds }
|
||||
|
||||
/**
|
||||
* Becomes the app that turns the calendar provider's reminder alarms into
|
||||
* visible notifications (the Etar model — the provider broadcasts
|
||||
* `EVENT_REMINDER` at reminder time but posts nothing itself).
|
||||
*
|
||||
* The broadcast's data URI only carries the alarm time, so it is ignored:
|
||||
* we query every still-scheduled, due `CalendarAlerts` row ourselves, post
|
||||
* them, and mark them fired. Posting happens before marking — a crash in
|
||||
* between re-posts silently (same tag) rather than losing the reminder.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class EventReminderReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var alertStore: ReminderAlertStore
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
@Inject lateinit var calendarPrefs: CalendarPrefs
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||
val readGranted = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (!readGranted || !notifier.canPost()) return
|
||||
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
if (settingsPrefs.remindersEnabled.first()) {
|
||||
val now = System.currentTimeMillis()
|
||||
val due = alertStore.dueAlerts(now)
|
||||
val disabled = calendarPrefs.disabledCalendarIds.first()
|
||||
// Suppress reminders for disabled calendars, but still mark
|
||||
// every due alert fired so the provider stops re-broadcasting
|
||||
// the suppressed ones.
|
||||
postableAlerts(due, disabled).forEach { notifier.post(it) }
|
||||
alertStore.markFired(due.map { it.alertId }, now)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-11
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.reminders
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -18,12 +19,14 @@ import javax.inject.Inject
|
||||
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
||||
* alarm), so the receiver is not exported.
|
||||
*
|
||||
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
|
||||
* already fired, so nothing re-posts it.
|
||||
* - **Dismiss** just cancels the notification — the scan's watermark has moved
|
||||
* past this reminder, so nothing re-posts it.
|
||||
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
||||
* it after the user's snooze delay.
|
||||
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
|
||||
* or dismiss it again.
|
||||
* or dismiss it again — unless the calendar was switched off during the
|
||||
* snooze, which [ReminderNotifier.post] catches — this alarm is its own
|
||||
* trigger, outside the ordinary scan.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ReminderActionReceiver : BroadcastReceiver() {
|
||||
@@ -73,7 +76,14 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
||||
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
|
||||
|
||||
private const val EXTRA_ALERT_ID = "alert_id"
|
||||
/**
|
||||
* Not handled here — the notification body opens the detail screen
|
||||
* directly. It only claims a slot in [requestCode] so that intent stays
|
||||
* distinct from the three this receiver does handle.
|
||||
*/
|
||||
const val ACTION_OPEN = "de.jeanlucmakiola.calendula.reminders.OPEN"
|
||||
|
||||
private const val EXTRA_ALERT_KEY = "alert_key"
|
||||
private const val EXTRA_EVENT_ID = "event_id"
|
||||
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||
private const val EXTRA_BEGIN = "begin"
|
||||
@@ -82,11 +92,16 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
private const val EXTRA_LOCATION = "location"
|
||||
private const val EXTRA_ALL_DAY = "all_day"
|
||||
|
||||
/** An explicit intent to this receiver carrying [alert] as extras. */
|
||||
/**
|
||||
* An explicit intent to this receiver carrying [alert] as extras. The
|
||||
* data URI duplicates no information but is what keeps two reminders'
|
||||
* `PendingIntent`s apart — `filterEquals` never compares extras.
|
||||
*/
|
||||
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
||||
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||
this.action = action
|
||||
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
||||
data = "calendula://reminder/${alert.key}".toUri()
|
||||
putExtra(EXTRA_ALERT_KEY, alert.key)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||
@@ -97,23 +112,25 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable request code per (alert, action) so the three PendingIntents
|
||||
* of one notification stay distinct and don't clobber each other.
|
||||
* A stable request code per (alert, action), so one notification's
|
||||
* PendingIntents stay distinct. The shift keeps the action slot intact;
|
||||
* the top bits it drops are separated by [intent]'s per-reminder URI.
|
||||
*/
|
||||
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||
val actionOffset = when (action) {
|
||||
ACTION_SNOOZE -> 1
|
||||
ACTION_DISMISS -> 2
|
||||
ACTION_SHOW -> 3
|
||||
ACTION_OPEN -> 4
|
||||
else -> 0
|
||||
}
|
||||
return alert.alertId.toInt() * 8 + actionOffset
|
||||
return (alert.key.toInt() shl 3) + actionOffset
|
||||
}
|
||||
|
||||
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
|
||||
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
|
||||
return ReminderAlert(
|
||||
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
||||
key = intent.getLongExtra(EXTRA_ALERT_KEY, 0L),
|
||||
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
||||
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.content.getSystemService
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* True on API < 31 (no restriction), and on 31+ when the exact-alarm capability
|
||||
* is held — auto-granted via `USE_EXACT_ALARM` on API 33+ (Calendula is a
|
||||
* calendar app), user-revocable on 31–32.
|
||||
*/
|
||||
internal fun AlarmManager.canScheduleExactCompat(): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
|
||||
|
||||
/**
|
||||
* Holds the app's own wake-up for the next reminder (#75). Exactly one alarm
|
||||
* exists at a time, for the earliest reminder ahead; every firing re-scans and
|
||||
* re-arms. Exact, with an inexact allow-while-idle fallback where the OS
|
||||
* withholds the capability (API 31–32 with the permission revoked).
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderAlarmScheduler @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
fun scheduleScan(triggerAtMillis: Long) {
|
||||
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||
val pendingIntent = scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
if (alarmManager.canScheduleExactCompat()) {
|
||||
alarmManager.setExactAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
} else {
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */
|
||||
fun cancelScan() {
|
||||
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||
alarmManager.cancel(scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
}
|
||||
|
||||
private fun scanPendingIntent(flags: Int): PendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
SCAN_REQUEST_CODE,
|
||||
Intent(context, ReminderScheduleReceiver::class.java)
|
||||
.setAction(ReminderScheduleReceiver.ACTION_SCAN),
|
||||
flags or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
// Fixed: there is only ever one scan alarm, and re-arming must replace it.
|
||||
const val SCAN_REQUEST_CODE = 0x5CA1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
|
||||
|
||||
/**
|
||||
* One reminder as the notification layer needs it: what to show, and the stable
|
||||
* [key] identifying it across a reboot, a re-scan and a reinstall. Derived from
|
||||
* the reminder itself (see [PlannedReminder.key]) — in-house delivery has no
|
||||
* `CalendarAlerts` row to take an id from (#75).
|
||||
*/
|
||||
data class ReminderAlert(
|
||||
val key: Long,
|
||||
val eventId: Long,
|
||||
val calendarId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||
val title: String,
|
||||
val location: String?,
|
||||
val isAllDay: Boolean,
|
||||
)
|
||||
|
||||
fun PlannedReminder.toAlert(): ReminderAlert = ReminderAlert(
|
||||
key = key,
|
||||
eventId = instance.eventId,
|
||||
calendarId = instance.calendarId,
|
||||
beginMillis = instance.beginMillis,
|
||||
endMillis = instance.endMillis,
|
||||
title = instance.title,
|
||||
location = instance.location,
|
||||
isAllDay = instance.isAllDay,
|
||||
)
|
||||
@@ -1,115 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* One due row of the provider's `CalendarAlerts` table (a join with Events).
|
||||
* Stays in the data layer: alerts feed the notification path only and never
|
||||
* reach a screen, so there is no domain model for them.
|
||||
*/
|
||||
data class ReminderAlert(
|
||||
val alertId: Long,
|
||||
val eventId: Long,
|
||||
val calendarId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||
val title: String,
|
||||
val location: String?,
|
||||
val isAllDay: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Seam over the `CalendarAlerts` table so the receiver logic can be exercised
|
||||
* without a ContentResolver. The provider creates these rows itself — only
|
||||
* for `METHOD_ALERT` reminders (verified in AOSP `CalendarAlarmManager`), so
|
||||
* email reminders never show up here.
|
||||
*/
|
||||
interface ReminderAlertStore {
|
||||
|
||||
/** Alerts that are due (`ALARM_TIME` has passed) and still unhandled. */
|
||||
fun dueAlerts(nowMillis: Long): List<ReminderAlert>
|
||||
|
||||
/**
|
||||
* Mark the given alerts handled (`STATE_FIRED`) so a later broadcast does
|
||||
* not surface them again. Best effort: this write needs `WRITE_CALENDAR`,
|
||||
* which the user may have declined — then re-broadcasts silently replace
|
||||
* the already-posted notifications instead (same tag, alert-once).
|
||||
*/
|
||||
fun markFired(alertIds: List<Long>, nowMillis: Long)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class AndroidReminderAlertStore @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ReminderAlertStore {
|
||||
|
||||
override fun dueAlerts(nowMillis: Long): List<ReminderAlert> = context.contentResolver.query(
|
||||
CalendarContract.CalendarAlerts.CONTENT_URI,
|
||||
PROJECTION,
|
||||
CalendarContract.CalendarAlerts.STATE + " = ? AND " +
|
||||
CalendarContract.CalendarAlerts.ALARM_TIME + " <= ?",
|
||||
arrayOf(
|
||||
CalendarContract.CalendarAlerts.STATE_SCHEDULED.toString(),
|
||||
nowMillis.toString(),
|
||||
),
|
||||
CalendarContract.CalendarAlerts.BEGIN + " ASC",
|
||||
)?.use { c ->
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
add(
|
||||
ReminderAlert(
|
||||
alertId = c.getLong(0),
|
||||
eventId = c.getLong(1),
|
||||
calendarId = c.getLong(2),
|
||||
beginMillis = c.getLong(3),
|
||||
endMillis = c.getLong(4),
|
||||
title = c.getString(5).orEmpty(),
|
||||
location = c.getString(6)?.takeIf { it.isNotBlank() },
|
||||
isAllDay = c.getInt(7) == 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
override fun markFired(alertIds: List<Long>, nowMillis: Long) {
|
||||
if (alertIds.isEmpty()) return
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.CalendarAlerts.STATE, CalendarContract.CalendarAlerts.STATE_FIRED)
|
||||
put(CalendarContract.CalendarAlerts.RECEIVED_TIME, nowMillis)
|
||||
put(CalendarContract.CalendarAlerts.NOTIFY_TIME, nowMillis)
|
||||
}
|
||||
try {
|
||||
context.contentResolver.update(
|
||||
CalendarContract.CalendarAlerts.CONTENT_URI,
|
||||
values,
|
||||
CalendarContract.CalendarAlerts._ID +
|
||||
" IN (" + alertIds.joinToString(",") + ")",
|
||||
null,
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Cannot mark alerts fired without WRITE_CALENDAR", e)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderAlertStore"
|
||||
val PROJECTION = arrayOf(
|
||||
CalendarContract.CalendarAlerts._ID,
|
||||
CalendarContract.CalendarAlerts.EVENT_ID,
|
||||
CalendarContract.CalendarAlerts.CALENDAR_ID,
|
||||
CalendarContract.CalendarAlerts.BEGIN,
|
||||
CalendarContract.CalendarAlerts.END,
|
||||
CalendarContract.CalendarAlerts.TITLE,
|
||||
CalendarContract.CalendarAlerts.EVENT_LOCATION,
|
||||
CalendarContract.CalendarAlerts.ALL_DAY,
|
||||
)
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContentUris
|
||||
import android.provider.CalendarContract
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The read side of in-house reminder delivery: occurrences and their reminder
|
||||
* offsets, read from `Instances` and `Reminders` rather than `CalendarAlerts`,
|
||||
* which cannot be assumed to be written (#75).
|
||||
*
|
||||
* An interface so [ReminderScanner] can be exercised on the JVM.
|
||||
*/
|
||||
interface ReminderInstanceSource {
|
||||
|
||||
/** Occurrences overlapping `[fromMillis, toMillis]`, of switched-on calendars. */
|
||||
fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance>
|
||||
|
||||
/** `METHOD_ALERT` reminder offsets per event id, for the given events. */
|
||||
fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>>
|
||||
|
||||
/**
|
||||
* The largest `METHOD_ALERT` offset anywhere in the table, so the query
|
||||
* window can be stretched to cover it and a long-lead reminder is planned
|
||||
* before it comes due rather than firing late.
|
||||
*/
|
||||
fun longestReminderMinutes(): Int
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class ProviderReminderInstanceSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ReminderInstanceSource {
|
||||
|
||||
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
|
||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||
ContentUris.appendId(this, fromMillis)
|
||||
ContentUris.appendId(this, toMillis)
|
||||
}.build()
|
||||
// `visible` is the flag the app's one visibility model writes (#75).
|
||||
// The status clause mirrors CalendarDataSource.instances: NULL means
|
||||
// "normal", so a bare `!= CANCELED` would drop every ordinary event.
|
||||
// An invitation the user declined is answered — it stays on the calendar
|
||||
// struck through, but it plans nothing (#180). NULL again means "no
|
||||
// answer recorded", which is not a "no".
|
||||
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
|
||||
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||
"${CalendarContract.Instances.STATUS} != " +
|
||||
"${CalendarContract.Events.STATUS_CANCELED}) AND " +
|
||||
"(${CalendarContract.Instances.SELF_ATTENDEE_STATUS} IS NULL OR " +
|
||||
"${CalendarContract.Instances.SELF_ATTENDEE_STATUS} != " +
|
||||
"${CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED})"
|
||||
return context.contentResolver.query(
|
||||
uri, OCCURRENCE_PROJECTION, selection, null, null,
|
||||
)?.use { c ->
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
add(
|
||||
ReminderEventInstance(
|
||||
eventId = c.getLong(0),
|
||||
calendarId = c.getLong(1),
|
||||
beginMillis = c.getLong(2),
|
||||
endMillis = if (c.isNull(3)) 0L else c.getLong(3),
|
||||
title = c.getString(4).orEmpty(),
|
||||
location = c.getString(5)?.takeIf { it.isNotBlank() },
|
||||
isAllDay = c.getInt(6) == 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
override fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>> {
|
||||
if (eventIds.isEmpty()) return emptyMap()
|
||||
val out = mutableMapOf<Long, MutableList<Int>>()
|
||||
// Batched: the ids go into the selection literally, and an unbounded
|
||||
// `IN (...)` would grow the SQL past what SQLite takes.
|
||||
eventIds.distinct().chunked(EVENT_ID_BATCH).forEach { batch ->
|
||||
context.contentResolver.query(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
REMINDER_PROJECTION,
|
||||
"${CalendarContract.Reminders.METHOD} = " +
|
||||
"${CalendarContract.Reminders.METHOD_ALERT} AND " +
|
||||
"${CalendarContract.Reminders.EVENT_ID} IN (${batch.joinToString(",")})",
|
||||
null,
|
||||
null,
|
||||
)?.use { c ->
|
||||
while (c.moveToNext()) {
|
||||
out.getOrPut(c.getLong(0)) { mutableListOf() } += c.getInt(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
override fun longestReminderMinutes(): Int = context.contentResolver.query(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Reminders.MINUTES),
|
||||
"${CalendarContract.Reminders.METHOD} = ${CalendarContract.Reminders.METHOD_ALERT}",
|
||||
null,
|
||||
// One row is enough: the provider passes the sort order to SQLite.
|
||||
"${CalendarContract.Reminders.MINUTES} DESC",
|
||||
)?.use { c -> if (c.moveToFirst()) c.getInt(0) else 0 } ?: 0
|
||||
|
||||
private companion object {
|
||||
const val EVENT_ID_BATCH = 50
|
||||
|
||||
val OCCURRENCE_PROJECTION = arrayOf(
|
||||
CalendarContract.Instances.EVENT_ID,
|
||||
CalendarContract.Instances.CALENDAR_ID,
|
||||
CalendarContract.Instances.BEGIN,
|
||||
CalendarContract.Instances.END,
|
||||
CalendarContract.Instances.TITLE,
|
||||
CalendarContract.Instances.EVENT_LOCATION,
|
||||
CalendarContract.Instances.ALL_DAY,
|
||||
)
|
||||
|
||||
val REMINDER_PROJECTION = arrayOf(
|
||||
CalendarContract.Reminders.EVENT_ID,
|
||||
CalendarContract.Reminders.MINUTES,
|
||||
)
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The backstop under the alarm: a daily scan that runs whether or not the alarm
|
||||
* survived. Finds nothing to do in the steady state; it exists for the device
|
||||
* that quietly drops the alarm without a reboot to announce it (#75).
|
||||
*/
|
||||
object ReminderMaintenanceScheduler {
|
||||
|
||||
private const val WORK_NAME = "reminder-scan-maintenance"
|
||||
|
||||
/** Enqueue the daily backstop; idempotent, so every launch may call it. */
|
||||
fun apply(context: Context) {
|
||||
val request = PeriodicWorkRequestBuilder<ReminderMaintenanceWorker>(1, TimeUnit.DAYS)
|
||||
// The launch scan covers now; let the first periodic run wait.
|
||||
.setInitialDelay(1, TimeUnit.DAYS)
|
||||
.build()
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||
}
|
||||
}
|
||||
|
||||
class ReminderMaintenanceWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Deps {
|
||||
fun reminderScanner(): ReminderScanner
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result = try {
|
||||
EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||
.reminderScanner()
|
||||
.scan()
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
// The scan swallows its own failures, so anything reaching here is the
|
||||
// entry point itself — a retry will not mend it.
|
||||
Log.w(TAG, "Reminder maintenance scan failed", e)
|
||||
Result.success()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderMaintenance"
|
||||
}
|
||||
}
|
||||
@@ -14,25 +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. */
|
||||
@@ -43,18 +53,44 @@ class ReminderNotifier @Inject constructor(
|
||||
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
suspend 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,
|
||||
todayLabel = context.getString(R.string.reminder_day_today),
|
||||
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)
|
||||
@@ -81,16 +117,18 @@ class ReminderNotifier @Inject constructor(
|
||||
.build()
|
||||
try {
|
||||
NotificationManagerCompat.from(context)
|
||||
.notify(alert.alertId.toString(), NOTIFICATION_ID, notification)
|
||||
.notify(alert.key.toString(), NOTIFICATION_ID, notification)
|
||||
} catch (e: SecurityException) {
|
||||
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
||||
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
||||
}
|
||||
// Handled either way — a retry hits the same revoked permission.
|
||||
return true
|
||||
}
|
||||
|
||||
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||
fun cancel(alert: ReminderAlert) {
|
||||
NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID)
|
||||
NotificationManagerCompat.from(context).cancel(alert.key.toString(), NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
||||
@@ -103,7 +141,10 @@ class ReminderNotifier @Inject constructor(
|
||||
|
||||
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
/* requestCode = */ alert.alertId.toInt(),
|
||||
// Shares the per-(alert, action) request-code scheme with the buttons.
|
||||
/* requestCode = */ ReminderActionReceiver.requestCode(
|
||||
alert, ReminderActionReceiver.ACTION_OPEN,
|
||||
),
|
||||
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* One pass of in-house reminder delivery: read what is planned, post what has
|
||||
* come due, and arm the next wake-up. Every trigger runs the same [scan], and
|
||||
* re-running is always safe — the watermark in [ReminderStatePrefs] decides what
|
||||
* is owed, not the trigger.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderScanner @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val source: ReminderInstanceSource,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
private val notifier: ReminderNotifier,
|
||||
private val alarms: ReminderAlarmScheduler,
|
||||
private val state: ReminderStatePrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: kotlinx.coroutines.CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
// Triggers overlap freely (an alarm during a burst of edits); serialize so
|
||||
// two passes can't both read the same watermark and post the same reminder.
|
||||
private val scanLock = Mutex()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + io)
|
||||
private val providerChanges = MutableSharedFlow<Unit>(
|
||||
replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
private var watching = false
|
||||
|
||||
suspend fun scan() = withContext(io) {
|
||||
scanLock.withLock {
|
||||
try {
|
||||
runScan()
|
||||
} catch (e: SecurityException) {
|
||||
// Permission revoked mid-flight; the next grant re-scans.
|
||||
Log.w(TAG, "Reminder scan lacks the calendar permission", e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Reminder scan failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runScan() {
|
||||
val now = System.currentTimeMillis()
|
||||
if (!hasReadCalendar()) return
|
||||
|
||||
if (!settingsPrefs.remindersEnabled.first()) {
|
||||
// Reminders off: drop the wake-up, but keep the watermark moving
|
||||
// so switching them back on doesn't replay the backlog.
|
||||
alarms.cancelScan()
|
||||
state.setLastScanMillis(now)
|
||||
return
|
||||
}
|
||||
|
||||
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
|
||||
// Reach into the past too: an all-day "at time of event" encodes to a
|
||||
// negative offset, and a catch-up pass needs the occurrences it missed.
|
||||
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
|
||||
val planned = planReminders(
|
||||
instances = occurrences,
|
||||
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId })
|
||||
.withoutProviderDefaults(),
|
||||
zone = ZoneId.systemDefault(),
|
||||
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
|
||||
)
|
||||
|
||||
val schedule = scheduleReminders(
|
||||
planned = planned,
|
||||
lastFiredMillis = reminderWatermark(state.lastScanMillis(), now),
|
||||
nowMillis = now,
|
||||
horizonMillis = now + MAX_ALARM_INTERVAL_MILLIS,
|
||||
)
|
||||
|
||||
if (notifier.canPost()) {
|
||||
schedule.due.forEach { notifier.post(it.toAlert()) }
|
||||
}
|
||||
// Advance even when nothing could be posted, so muting notifications
|
||||
// doesn't build a backlog.
|
||||
state.setLastScanMillis(now)
|
||||
alarms.scheduleScan(schedule.nextAlarmMillis)
|
||||
}
|
||||
|
||||
private fun hasReadCalendar(): Boolean = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/**
|
||||
* Re-scan when the provider changes, so a saved or deleted event re-arms the
|
||||
* alarm at once. Debounced, since a single save lands as several
|
||||
* notifications. Process-lifetime only; other triggers cover the rest.
|
||||
*/
|
||||
fun startWatchingProvider() {
|
||||
if (watching) return
|
||||
watching = true
|
||||
providerChanges
|
||||
.debounce(PROVIDER_CHANGE_DEBOUNCE_MILLIS)
|
||||
.onEach { scan() }
|
||||
.launchIn(scope)
|
||||
calendarDataSource.registerChangeListener { providerChanges.tryEmit(Unit) }
|
||||
}
|
||||
|
||||
/** Fire-and-forget scan for callers that are not in a coroutine already. */
|
||||
fun scanInBackground() {
|
||||
scope.launch(start = CoroutineStart.DEFAULT) { scan() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderScanner"
|
||||
|
||||
/**
|
||||
* How far ahead occurrences are read. Stretched further by the longest
|
||||
* reminder offset in the table, so this is only the floor.
|
||||
*/
|
||||
const val LOOKAHEAD_MILLIS = 7L * 24 * 60 * 60 * 1000
|
||||
|
||||
/** How far back to look for occurrences that may still owe a reminder. */
|
||||
const val PAST_WINDOW_MILLIS = 24L * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Never wait longer than a day for the next pass: it rolls the lookahead
|
||||
* window forward and re-arms an alarm the system may have dropped.
|
||||
*/
|
||||
const val MAX_ALARM_INTERVAL_MILLIS = 24L * 60 * 60 * 1000
|
||||
|
||||
const val PROVIDER_CHANGE_DEBOUNCE_MILLIS = 2_000L
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Every out-of-process reason to re-run a reminder scan: our own [ACTION_SCAN]
|
||||
* alarm, boot and package-replaced (both wipe pending alarms), and time or
|
||||
* timezone changes (both move reminders relative to the armed alarm). All do the
|
||||
* same thing, since [ReminderScanner.scan] is idempotent.
|
||||
*
|
||||
* Exported for the system broadcasts; an early scan triggered by another app is
|
||||
* harmless.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ReminderScheduleReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var scanner: ReminderScanner
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
// Checked despite every action doing the same thing: the receiver is
|
||||
// exported and the broadcasts it takes are protected, so any other
|
||||
// action did not come from where it claims to.
|
||||
if (intent.action !in HANDLED_ACTIONS) return
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
scanner.scan()
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_SCAN = "de.jeanlucmakiola.calendula.reminders.SCAN"
|
||||
|
||||
private val HANDLED_ACTIONS = setOf(
|
||||
ACTION_SCAN,
|
||||
Intent.ACTION_BOOT_COMPLETED,
|
||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||
Intent.ACTION_TIME_CHANGED,
|
||||
Intent.ACTION_TIMEZONE_CHANGED,
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
-9
@@ -10,14 +10,13 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
|
||||
* Schedules a one-off exact alarm that re-shows a snoozed reminder. Separate
|
||||
* from [ReminderAlarmScheduler]'s single moving scan alarm: a snooze is pinned
|
||||
* to one reminder and has to outlive the watermark moving past it, so it carries
|
||||
* the reminder in its own intent.
|
||||
*
|
||||
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
|
||||
* broadcast (the Etar model), but a snoozed reminder has no provider backing —
|
||||
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
|
||||
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
|
||||
* back to an inexact allow-while-idle alarm only if the OS withholds the
|
||||
* exact-alarm capability (API 31–32 where the user revoked it).
|
||||
* Falls back to an inexact allow-while-idle alarm where the OS withholds the
|
||||
* exact-alarm capability (API 31–32 with the permission revoked).
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderSnoozeScheduler @Inject constructor(
|
||||
@@ -37,8 +36,8 @@ class ReminderSnoozeScheduler @Inject constructor(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
} else {
|
||||
// Exact alarms revoked (API 31–32): an inexact wake is the honest
|
||||
// best we can do without nagging for SCHEDULE_EXACT_ALARM.
|
||||
// Exact alarms revoked (API 31–32); an inexact wake is the best
|
||||
// available without nagging for SCHEDULE_EXACT_ALARM.
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
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)
|
||||
* - all-day, one day: "11 Jun 2026"
|
||||
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
||||
* 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:
|
||||
*
|
||||
* All-day instances store UTC midnights with an exclusive end, so they are
|
||||
* read in UTC and the end day is the last *covered* day.
|
||||
* - 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: "Tomorrow" / "Thu" / "11 Jun 2026" (same relative rules,
|
||||
* plus [todayLabel] — no time range to lean on)
|
||||
* - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end)
|
||||
*
|
||||
* All-day instances store UTC midnights with an exclusive end, so they are read
|
||||
* in UTC and the end day is the last *covered* day (issue #308).
|
||||
*/
|
||||
fun reminderTimeText(
|
||||
beginMillis: Long,
|
||||
@@ -27,6 +41,11 @@ fun reminderTimeText(
|
||||
zone: ZoneId,
|
||||
locale: Locale,
|
||||
is24Hour: Boolean,
|
||||
today: LocalDate,
|
||||
firstDayOfWeek: DayOfWeek,
|
||||
todayLabel: String,
|
||||
tomorrowLabel: String,
|
||||
yesterdayLabel: String,
|
||||
): String {
|
||||
if (isAllDay) {
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
@@ -35,26 +54,83 @@ fun reminderTimeText(
|
||||
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
.minusDays(1)
|
||||
.coerceAtLeast(firstDay)
|
||||
return if (lastDay == firstDay) {
|
||||
dateFormat.format(firstDay)
|
||||
} else {
|
||||
dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay)
|
||||
}
|
||||
val start = relativeDayPrefix(
|
||||
day = firstDay,
|
||||
today = today,
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
locale = locale,
|
||||
dateFormat = dateFormat,
|
||||
tomorrowLabel = tomorrowLabel,
|
||||
yesterdayLabel = yesterdayLabel,
|
||||
) ?: todayLabel
|
||||
return if (lastDay == firstDay) start else start + RANGE + dateFormat.format(lastDay)
|
||||
}
|
||||
|
||||
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
||||
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
||||
return if (begin.toLocalDate() == end.toLocalDate()) {
|
||||
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||
val range = timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||
val prefix = relativeDayPrefix(
|
||||
day = begin.toLocalDate(),
|
||||
today = today,
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
locale = locale,
|
||||
dateFormat = dateFormat,
|
||||
tomorrowLabel = tomorrowLabel,
|
||||
yesterdayLabel = yesterdayLabel,
|
||||
)
|
||||
if (prefix == null) range else "$prefix, $range"
|
||||
} else {
|
||||
// Cross-day: medium date + the chosen short time, joined per side. Built
|
||||
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
|
||||
// applies to the time portion too.
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
// applies to the time portion too. The explicit dates already say which
|
||||
// day, so no relative prefix is layered on top.
|
||||
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
|
||||
dateTime(begin) + RANGE + dateTime(end)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A short label for [day] relative to [today], or `null` when it *is* today (the
|
||||
* common case, which needs no prefix). Weekday names are used only within the
|
||||
* current week — a "next Wednesday" would be indistinguishable from this one, so
|
||||
* anything past this week falls back to the exact date.
|
||||
*/
|
||||
private fun relativeDayPrefix(
|
||||
day: LocalDate,
|
||||
today: LocalDate,
|
||||
firstDayOfWeek: DayOfWeek,
|
||||
locale: Locale,
|
||||
dateFormat: DateTimeFormatter,
|
||||
tomorrowLabel: String,
|
||||
yesterdayLabel: String,
|
||||
): String? = when (ChronoUnit.DAYS.between(today, day)) {
|
||||
0L -> null
|
||||
1L -> tomorrowLabel
|
||||
-1L -> yesterdayLabel
|
||||
else -> if (isSameWeek(day, today, firstDayOfWeek)) {
|
||||
day.dayOfWeek.getDisplayName(TextStyle.SHORT, locale)
|
||||
} else {
|
||||
dateFormat.format(day)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [day] and [today] share the same week. The week boundary honours the
|
||||
* user's *week starts on* setting (already resolved to a concrete [firstDayOfWeek],
|
||||
* with [firstDayOfWeek] falling back to the locale default upstream).
|
||||
*/
|
||||
private fun isSameWeek(day: LocalDate, today: LocalDate, firstDayOfWeek: DayOfWeek): Boolean {
|
||||
val startOfWeek = today.previousOrSame(firstDayOfWeek)
|
||||
return !day.isBefore(startOfWeek) && day.isBefore(startOfWeek.plusWeeks(1))
|
||||
}
|
||||
|
||||
/** The most recent [target] on or before this date (this date itself when it matches). */
|
||||
private fun LocalDate.previousOrSame(target: DayOfWeek): LocalDate {
|
||||
val backtrack = (dayOfWeek.value - target.value + 7) % 7
|
||||
return minusDays(backtrack.toLong())
|
||||
}
|
||||
|
||||
private const val RANGE = " – "
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
/**
|
||||
* The ways a calendar can behave unlike a plain, writable one — each a reason it
|
||||
* is missing from the event and import pickers (#76).
|
||||
*/
|
||||
enum class CalendarStateLabel {
|
||||
/**
|
||||
* A special-dates mirror the app fills from contacts. Writable and visible,
|
||||
* yet no event target: anything authored here is deleted by the next sync.
|
||||
*/
|
||||
MANAGED,
|
||||
|
||||
/** Contents can't be modified: a WebCal subscription, a read-only share. */
|
||||
READ_ONLY,
|
||||
|
||||
/** The account holds the events, but this device isn't syncing them down. */
|
||||
NOT_SYNCED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account keeps this calendar's events off the device
|
||||
* (`Calendars.SYNC_EVENTS = 0`) — empty by construction. Device-local calendars
|
||||
* are excluded: nothing syncs them by definition, and one from another app can
|
||||
* hold real events at `sync_events = 0`.
|
||||
*/
|
||||
val CalendarSource.isNotSynced: Boolean
|
||||
get() = !syncsEvents && !isLocal
|
||||
|
||||
/**
|
||||
* Whether a visibility switch on this calendar can change anything the user
|
||||
* would see — it can't for a non-syncing one, with no events on the device.
|
||||
*/
|
||||
val CalendarSource.hasVisibilitySwitch: Boolean
|
||||
get() = !isNotSynced
|
||||
|
||||
/**
|
||||
* Whether this calendar can be offered as a target for a new or imported event.
|
||||
* The one predicate behind both pickers, so the states [CalendarStateLabel]
|
||||
* names on a manager row are exactly the states that keep a calendar out of them
|
||||
* (#76). An event already living in an excluded calendar keeps it; the editor
|
||||
* adds that calendar back to its picker.
|
||||
*/
|
||||
val CalendarSource.isEventTarget: Boolean
|
||||
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
|
||||
|
||||
/**
|
||||
* Whether this calendar's events may have their times rewritten by a drag (#68).
|
||||
* Deliberately not [isEventTarget]: a managed event stays editable (reminders,
|
||||
* notes) yet must never move, since the next contacts sync would put it back.
|
||||
*/
|
||||
val CalendarSource.allowsEventMove: Boolean
|
||||
get() = canModifyContents && !isManaged
|
||||
|
||||
/** Every state worth naming on this calendar's row, in reading order. */
|
||||
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
||||
if (isManaged) add(CalendarStateLabel.MANAGED)
|
||||
if (!canModifyContents) add(CalendarStateLabel.READ_ONLY)
|
||||
if (isNotSynced) add(CalendarStateLabel.NOT_SYNCED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar-manager order within one group: the ones you can actually act on
|
||||
* first, the non-syncing ones after them. Stable otherwise, so the provider's
|
||||
* display-name ordering survives.
|
||||
*/
|
||||
fun List<CalendarSource>.orderedForManager(): List<CalendarSource> =
|
||||
sortedBy { it.isNotSynced }
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
/**
|
||||
* The `Calendars.VISIBLE` writes that flush the app's pending "switched off"
|
||||
* set into the provider, plus the ids that need no write at all.
|
||||
*/
|
||||
data class CalendarVisibilityPlan(
|
||||
val hide: Set<Long> = emptySet(),
|
||||
val settled: Set<Long> = emptySet(),
|
||||
) {
|
||||
val isEmpty: Boolean get() = hide.isEmpty() && settled.isEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile [pendingDisabledIds] — switch-offs the app could not write, plus
|
||||
* what the retired app-local visibility model left behind (#75) — against the
|
||||
* calendars actually on the device.
|
||||
*
|
||||
* Only ever *hides*: switching a system-hidden calendar back on would un-hide it
|
||||
* in every other calendar app too. Calendula follows the flag and explains
|
||||
* itself once (see [hasSystemHiddenCalendars]).
|
||||
*
|
||||
* [CalendarVisibilityPlan.settled] carries the ids needing no write — already
|
||||
* hidden, or gone from the device.
|
||||
*/
|
||||
fun calendarVisibilityPlan(
|
||||
calendars: List<CalendarSource>,
|
||||
pendingDisabledIds: Set<Long>,
|
||||
): CalendarVisibilityPlan {
|
||||
val byId = calendars.associateBy { it.id }
|
||||
val hide = mutableSetOf<Long>()
|
||||
val settled = mutableSetOf<Long>()
|
||||
for (id in pendingDisabledIds) {
|
||||
val calendar = byId[id]
|
||||
// Gone from the device, or already invisible — nothing to write.
|
||||
if (calendar != null && calendar.isVisibleInSystem) hide += id else settled += id
|
||||
}
|
||||
return CalendarVisibilityPlan(hide = hide, settled = settled)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any calendar is switched off at system level without Calendula having
|
||||
* asked for it — the condition the one-time notice explains.
|
||||
*/
|
||||
fun hasSystemHiddenCalendars(
|
||||
calendars: List<CalendarSource>,
|
||||
pendingDisabledIds: Set<Long>,
|
||||
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
|
||||
|
||||
/**
|
||||
* The failure a calendar list implies, or null when there is something to show.
|
||||
* Distinguishes "the device has no calendars" from "every one of them is
|
||||
* switched off" (#239) — both leave the grid empty, but only the second is
|
||||
* fixed from Settings → Calendars.
|
||||
*
|
||||
* The filter sheet's own hidden set is deliberately not folded in: emptying it
|
||||
* is a deliberate act taken moments earlier, in a sheet that is still one tap
|
||||
* away.
|
||||
*/
|
||||
fun calendarListFailure(calendars: List<CalendarSource>): FailureReason? = when {
|
||||
calendars.isEmpty() -> FailureReason.NoCalendarsConfigured
|
||||
calendars.none { it.isVisibleInSystem } -> FailureReason.AllCalendarsHidden
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* The palette entry closest to [argb], or null when the account publishes no
|
||||
* palette (its calendars then take a raw `EVENT_COLOR` instead).
|
||||
*
|
||||
* Used to land a colour that came from outside the account — an imported `.ics`
|
||||
* carries an arbitrary ARGB, while a palette calendar only accepts one of its
|
||||
* own keys. Distance is measured in Oklab, so the match is the one that looks
|
||||
* closest rather than the one that is closest in RGB coordinates.
|
||||
*/
|
||||
fun List<EventColorOption>.nearestTo(argb: Int): EventColorOption? {
|
||||
val target = oklchOf(argb)
|
||||
return minByOrNull { oklchOf(it.argb).distanceTo(target) }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.TimeZone
|
||||
|
||||
/** Lower values appear first in lists and get the upper visible lanes in grids. */
|
||||
fun EventInstance.displayPriority(zone: TimeZone): Int = when {
|
||||
isDeclined -> 3
|
||||
spansMultipleDays(zone) -> 0
|
||||
isAllDay -> 1
|
||||
else -> 2
|
||||
}
|
||||
|
||||
/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */
|
||||
fun eventDisplayComparator(zone: TimeZone): Comparator<EventInstance> =
|
||||
compareBy<EventInstance> { it.displayPriority(zone) }
|
||||
.thenBy { it.start }
|
||||
.thenBy { it.title }
|
||||
.thenBy { it.instanceId }
|
||||
@@ -4,13 +4,14 @@ import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* User input for creating an event (and, from v1.3, editing one). Times are
|
||||
* wall-clock values in the device zone; the data layer translates them to
|
||||
* provider millis (all-day events normalise to UTC midnights there).
|
||||
* wall-clock values in [timezone]; the data layer translates them to provider
|
||||
* millis (all-day events normalise to UTC midnights there).
|
||||
*/
|
||||
data class EventForm(
|
||||
val calendarId: Long?,
|
||||
@@ -18,6 +19,23 @@ data class EventForm(
|
||||
val isAllDay: Boolean = false,
|
||||
val start: LocalDateTime,
|
||||
val end: LocalDateTime,
|
||||
/**
|
||||
* The zone [start]/[end] are wall-clock values in, or null to follow the
|
||||
* device — null is not "no zone", it is "whichever zone the device is in
|
||||
* when this is saved", which is what an event authored and lived in one
|
||||
* place wants. The data layer resolves it at write time and always stamps a
|
||||
* concrete `EVENT_TIMEZONE`.
|
||||
*
|
||||
* A non-null value pins the event to a zone regardless of where the device
|
||||
* is, so it keeps tracking that zone's offset across DST. [toEditForm] only
|
||||
* sets it when the stored zone differs from the device's, so merely opening
|
||||
* a local event never reveals the field — and re-opening a pinned one in
|
||||
* another zone round-trips it rather than silently re-anchoring it.
|
||||
*
|
||||
* Always null for all-day events: those are date-anchored, not zone-anchored
|
||||
* (see [EventFormField.Timezone] and the data layer's UTC-midnight rule).
|
||||
*/
|
||||
val timezone: String? = null,
|
||||
val location: String = "",
|
||||
val description: String = "",
|
||||
/** Reminder lead times in minutes before the start, deduplicated. */
|
||||
@@ -66,11 +84,19 @@ data class EventAttendee(
|
||||
|
||||
/**
|
||||
* The form's optional sections. Which ones show by default is a user setting;
|
||||
* the rest unfold behind a "more fields" button.
|
||||
* the rest unfold behind a "more fields" button. Declaration order is the order
|
||||
* they're offered in, so a new constant goes where it belongs on the form, not
|
||||
* at the end.
|
||||
*/
|
||||
enum class EventFormField {
|
||||
Location,
|
||||
Description,
|
||||
/**
|
||||
* Pins the event's wall-clock times to a zone. Offered right after the time
|
||||
* fields it qualifies, and suppressed entirely for all-day events, whose
|
||||
* dates are deliberately zone-free.
|
||||
*/
|
||||
Timezone,
|
||||
Reminders,
|
||||
Recurrence,
|
||||
Availability,
|
||||
@@ -100,8 +126,25 @@ enum class EventFormProblem {
|
||||
* All-day provider times are UTC midnights with an exclusive end; the form
|
||||
* shows the last covered day and keeps placeholder wall-clock times in case
|
||||
* the user switches the event to timed.
|
||||
*
|
||||
* A timed event stored in a zone other than [zone] is prefilled *in its own
|
||||
* zone* and keeps it pinned, so the wall-clock the form shows is the one the
|
||||
* event means ("the New York 09:00 call") and a later save re-anchors it to the
|
||||
* same zone rather than the device's.
|
||||
*/
|
||||
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
|
||||
// All-day events are date-anchored and carry a nominal "UTC" that is an
|
||||
// anchor, not a location, so they never pin a zone.
|
||||
val pinnedZone = if (instance.isAllDay) {
|
||||
null
|
||||
} else {
|
||||
eventTimezone
|
||||
?.takeIf { it != zone.id }
|
||||
// An unparseable id (a malformed sync row) can't be honoured or
|
||||
// shown; fall back to the device zone rather than failing the open.
|
||||
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
|
||||
}
|
||||
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
|
||||
val (start, end) = if (instance.isAllDay) {
|
||||
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
||||
.toLocalDateTime(TimeZone.UTC).date
|
||||
@@ -110,8 +153,8 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||
} else {
|
||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to
|
||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone)
|
||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
|
||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = instance.calendarId,
|
||||
@@ -119,6 +162,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
isAllDay = instance.isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
timezone = pinnedZone,
|
||||
location = instance.location.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
||||
@@ -176,6 +220,23 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
||||
rowEnd = instance.end,
|
||||
)
|
||||
|
||||
/**
|
||||
* The form's times as they land in [target] — what a pinned event's wall-clock
|
||||
* actually means where the user is standing. Null when there's nothing to
|
||||
* disambiguate: an unpinned event (already in [target]), one pinned to [target]
|
||||
* itself, an all-day event (no zone), or an unparseable pinned zone.
|
||||
*
|
||||
* The form edits a pinned event in its own zone, so this is what lets the UI
|
||||
* show the other side of the pair rather than making the user do the arithmetic.
|
||||
*/
|
||||
fun EventForm.timesIn(target: TimeZone): Pair<LocalDateTime, LocalDateTime>? {
|
||||
if (isAllDay) return null
|
||||
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return null
|
||||
if (pinned.id == target.id) return null
|
||||
return start.toInstant(pinned).toLocalDateTime(target) to
|
||||
end.toInstant(pinned).toLocalDateTime(target)
|
||||
}
|
||||
|
||||
/**
|
||||
* The optional sections that hold a value in [form] — when editing, these
|
||||
* must be visible regardless of the user's default-fields setting, or the
|
||||
@@ -184,6 +245,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
||||
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||
if (location.isNotBlank()) add(EventFormField.Location)
|
||||
if (description.isNotBlank()) add(EventFormField.Description)
|
||||
if (timezone != null) add(EventFormField.Timezone)
|
||||
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
||||
if (rrule != null) add(EventFormField.Recurrence)
|
||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** An event with the free text the search may have matched it on. */
|
||||
data class SearchCandidate(
|
||||
val event: EventInstance,
|
||||
val description: String? = null,
|
||||
/** Null when the event has none: [EventInstance.title] is then a display
|
||||
* placeholder, which the query must not catch on. */
|
||||
val title: String? = event.title,
|
||||
)
|
||||
|
||||
/** A matched run inside one of a hit's texts, for highlighting. */
|
||||
data class MatchSpan(val start: Int, val end: Int)
|
||||
|
||||
/** The part of a description a token matched, elided to what fits a row. */
|
||||
data class DescriptionSnippet(
|
||||
val text: String,
|
||||
val spans: List<MatchSpan>,
|
||||
)
|
||||
|
||||
/** One result: the event, plus where the query matched it. */
|
||||
data class SearchHit(
|
||||
val event: EventInstance,
|
||||
val titleSpans: List<MatchSpan> = emptyList(),
|
||||
val locationSpans: List<MatchSpan> = emptyList(),
|
||||
val descriptionSnippet: DescriptionSnippet? = null,
|
||||
/** Already over, so the row can say so. Finished-today counts. */
|
||||
val isPast: Boolean = false,
|
||||
)
|
||||
|
||||
/** One month's worth of hits, as the results list draws them under a header. */
|
||||
data class SearchMonth(
|
||||
val year: Int,
|
||||
/** 1–12, so the UI can build the month's first day for a localized label. */
|
||||
val monthNumber: Int,
|
||||
val hits: List<SearchHit>,
|
||||
)
|
||||
|
||||
/** A finished search: a calendar of hits, plus the description-only ones. */
|
||||
data class SearchResults(
|
||||
val months: List<SearchMonth>,
|
||||
val inDescriptions: List<SearchHit>,
|
||||
) {
|
||||
val isEmpty: Boolean get() = months.isEmpty() && inDescriptions.isEmpty()
|
||||
|
||||
/** Every hit, whichever section it sits in — for select-all and delete. */
|
||||
val allHits: List<SearchHit> get() = months.flatMap { it.hits } + inDescriptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Turning a typed query into the results list.
|
||||
*
|
||||
* An event has to carry *every* whitespace-separated token, each in any of
|
||||
* title / location / description; case is folded in Kotlin rather than by SQL's
|
||||
* ASCII-only `LIKE`, so "ärzte" finds "Ärzte".
|
||||
*
|
||||
* Results are a calendar, not a ranking: months from the current one, then
|
||||
* forwards, then backwards, and inside a month what is still to come before
|
||||
* what has passed. The day is the boundary, not the moment. A hit the query
|
||||
* only reached through its description is held back in
|
||||
* [SearchResults.inDescriptions] rather than dated among the real ones.
|
||||
*/
|
||||
object EventSearch {
|
||||
|
||||
/** Characters of description kept around the match in a row's snippet. */
|
||||
private const val SNIPPET_LENGTH = 96
|
||||
|
||||
/** Characters of lead-in shown before the match, when there is room. */
|
||||
private const val SNIPPET_LEAD = 24
|
||||
|
||||
private val WHITESPACE = Regex("\\s+")
|
||||
|
||||
/** Shared with the data layer so its SQL pre-filter tokenises identically. */
|
||||
fun tokenize(query: String): List<String> =
|
||||
query.trim().split(WHITESPACE).filter { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* Search [candidates] for [query], dropping any that don't carry every token.
|
||||
* [now] marks a hit as over, [todayStart] is where it stops being current.
|
||||
* [zone] places each event on the calendar through the span rule, so an
|
||||
* all-day event keeps its own day whichever side of UTC we are on (#82).
|
||||
*/
|
||||
fun search(
|
||||
candidates: List<SearchCandidate>,
|
||||
query: String,
|
||||
now: Instant,
|
||||
todayStart: Instant,
|
||||
zone: TimeZone,
|
||||
): SearchResults {
|
||||
val tokens = tokenize(query)
|
||||
if (tokens.isEmpty()) return SearchResults(emptyList(), emptyList())
|
||||
|
||||
val today = todayStart.toLocalDateTime(zone).date
|
||||
val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) }
|
||||
val upcoming = { m: Matched -> m.event.spanLastDay(zone) >= today }
|
||||
val order = compareBy<Matched> { if (upcoming(it)) 0 else 1 }
|
||||
.thenComparator { a, b ->
|
||||
// What is left reads forwards; what is behind us reads backwards
|
||||
// from today, nearest first.
|
||||
if (upcoming(a)) {
|
||||
a.event.dayStart(zone).compareTo(b.event.dayStart(zone))
|
||||
} else {
|
||||
b.event.dayStart(zone).compareTo(a.event.dayStart(zone))
|
||||
}
|
||||
}
|
||||
|
||||
val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly }
|
||||
return SearchResults(
|
||||
months = groupByMonth(dated, zone, todayStart),
|
||||
inDescriptions = inDescriptions.map { it.hit },
|
||||
)
|
||||
}
|
||||
|
||||
/** Months from the current one, then forwards, then backwards. */
|
||||
private fun groupByMonth(
|
||||
dated: List<Matched>,
|
||||
zone: TimeZone,
|
||||
todayStart: Instant,
|
||||
): List<SearchMonth> {
|
||||
val currentKey = monthKey(todayStart.toLocalDateTime(zone).date)
|
||||
return dated
|
||||
.groupBy { monthKey(it.event.spanFirstDay(zone)) }
|
||||
.entries
|
||||
.sortedWith(
|
||||
compareBy<Map.Entry<Int, List<Matched>>> {
|
||||
when {
|
||||
it.key == currentKey -> 0
|
||||
it.key > currentKey -> 1
|
||||
else -> 2
|
||||
}
|
||||
}.thenComparator { a, b ->
|
||||
if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key)
|
||||
},
|
||||
)
|
||||
.map { (key, entries) ->
|
||||
SearchMonth(
|
||||
year = key / 12,
|
||||
monthNumber = key % 12 + 1,
|
||||
hits = entries.map { it.hit },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Year and month as one comparable number. */
|
||||
private fun monthKey(date: LocalDate): Int = date.year * 12 + (date.month.number - 1)
|
||||
|
||||
private fun match(
|
||||
candidate: SearchCandidate,
|
||||
tokens: List<String>,
|
||||
now: Instant,
|
||||
zone: TimeZone,
|
||||
): Matched? {
|
||||
val title = candidate.title?.takeIf { it.isNotBlank() }
|
||||
val location = candidate.event.location?.takeIf { it.isNotBlank() }
|
||||
// Collapsed so a multi-line description matches the way it is drawn.
|
||||
val description = candidate.description
|
||||
?.replace(WHITESPACE, " ")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
// Mid-word counts: "termin" has to keep finding "Zahnarzttermin".
|
||||
val present = { token: String ->
|
||||
title?.contains(token, ignoreCase = true) == true ||
|
||||
location?.contains(token, ignoreCase = true) == true ||
|
||||
description?.contains(token, ignoreCase = true) == true
|
||||
}
|
||||
if (!tokens.all(present)) return null
|
||||
|
||||
val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty()
|
||||
val titleSpans = title?.let { spansIn(it, tokens) }.orEmpty()
|
||||
val locationSpans = location?.let { spansIn(it, tokens) }.orEmpty()
|
||||
return Matched(
|
||||
hit = SearchHit(
|
||||
event = candidate.event,
|
||||
titleSpans = titleSpans,
|
||||
locationSpans = locationSpans,
|
||||
descriptionSnippet = description
|
||||
?.takeIf { descriptionSpans.isNotEmpty() }
|
||||
?.let { snippet(it, descriptionSpans) },
|
||||
isPast = candidate.event.dayEnd(zone) < now,
|
||||
),
|
||||
// Nothing in the name or the place caught the query: it is only here
|
||||
// for something in its notes.
|
||||
descriptionOnly = titleSpans.isEmpty() && locationSpans.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Every occurrence of every token in [text], merged where they overlap. */
|
||||
private fun spansIn(text: String, tokens: List<String>): List<MatchSpan> {
|
||||
val raw = mutableListOf<MatchSpan>()
|
||||
for (token in tokens) {
|
||||
var index = text.indexOf(token, startIndex = 0, ignoreCase = true)
|
||||
while (index >= 0) {
|
||||
raw += MatchSpan(index, index + token.length)
|
||||
index = text.indexOf(token, startIndex = index + 1, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
if (raw.isEmpty()) return emptyList()
|
||||
val sorted = raw.sortedBy { it.start }
|
||||
val merged = mutableListOf(sorted.first())
|
||||
for (span in sorted.drop(1)) {
|
||||
val last = merged.last()
|
||||
if (span.start <= last.end) {
|
||||
merged[merged.lastIndex] = MatchSpan(last.start, maxOf(last.end, span.end))
|
||||
} else {
|
||||
merged += span
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* A window of [description] around its first match, elided at both ends,
|
||||
* with the spans rebased onto it and any falling outside dropped.
|
||||
*/
|
||||
private fun snippet(description: String, spans: List<MatchSpan>): DescriptionSnippet {
|
||||
if (description.length <= SNIPPET_LENGTH) {
|
||||
return DescriptionSnippet(description, spans)
|
||||
}
|
||||
val first = spans.first().start
|
||||
val start = (first - SNIPPET_LEAD).coerceIn(0, (description.length - SNIPPET_LENGTH))
|
||||
val end = (start + SNIPPET_LENGTH).coerceAtMost(description.length)
|
||||
val prefix = if (start > 0) "…" else ""
|
||||
val suffix = if (end < description.length) "…" else ""
|
||||
val text = prefix + description.substring(start, end) + suffix
|
||||
val shift = prefix.length - start
|
||||
val rebased = spans
|
||||
.filter { it.start >= start && it.end <= end }
|
||||
.map { MatchSpan(it.start + shift, it.end + shift) }
|
||||
return DescriptionSnippet(text, rebased)
|
||||
}
|
||||
|
||||
private data class Matched(val hit: SearchHit, val descriptionOnly: Boolean) {
|
||||
val event: EventInstance get() = hit.event
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's first day as an instant in [zone]. An all-day event sits at
|
||||
* UTC midnight, so its raw start would rank it on a day it isn't drawn on.
|
||||
*/
|
||||
private fun EventInstance.dayStart(zone: TimeZone): Instant =
|
||||
if (isAllDay) spanFirstDay(zone).atStartOfDayIn(zone) else start
|
||||
|
||||
/** The moment the event's last day is over in [zone], exclusive. */
|
||||
private fun EventInstance.dayEnd(zone: TimeZone): Instant =
|
||||
if (isAllDay) spanLastDay(zone).plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) else end
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The zone this form's wall-clock times mean, matching what the data layer
|
||||
* resolves them in at write time: the form's own pinned zone, else [deviceZone].
|
||||
*/
|
||||
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
|
||||
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
|
||||
|
||||
/**
|
||||
* The form moved so it starts at [newStart], keeping its **instant** duration —
|
||||
* a recurring event's length travels to the provider as `DURATION`, so keeping
|
||||
* wall clock instead would rewrite the series' length across a DST boundary.
|
||||
*
|
||||
* All-day events are date-anchored — use [shiftedByDays]; this returns them
|
||||
* untouched.
|
||||
*/
|
||||
fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
|
||||
if (isAllDay) return this
|
||||
val zone = resolvedZone(deviceZone)
|
||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
||||
return copy(
|
||||
start = newStart.toLocalDateTime(zone),
|
||||
end = (newStart + span).toLocalDateTime(zone),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The form moved [days] calendar days, keeping its time of day and its span.
|
||||
* All-day events are pure date arithmetic; a timed one preserves its **instant**
|
||||
* duration, for the same reason [shiftedTo] does.
|
||||
*/
|
||||
fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
|
||||
if (days == 0) return this
|
||||
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
|
||||
if (isAllDay) {
|
||||
return copy(
|
||||
start = newStart,
|
||||
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time),
|
||||
)
|
||||
}
|
||||
val zone = resolvedZone(deviceZone)
|
||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
||||
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
|
||||
}
|
||||
@@ -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
|
||||
@@ -34,6 +43,13 @@ data class CalendarSource(
|
||||
* even after a backup restore clears the app's stored ids.
|
||||
*/
|
||||
val isManaged: Boolean = false,
|
||||
/**
|
||||
* Whether the provider keeps this calendar's events on the device
|
||||
* (`Calendars.SYNC_EVENTS`), independent of [isVisibleInSystem]. Says
|
||||
* nothing about device-local calendars, which can hold events with it off.
|
||||
* Read for the "not synced" row label (#76).
|
||||
*/
|
||||
val syncsEvents: Boolean = true,
|
||||
)
|
||||
|
||||
data class EventInstance(
|
||||
@@ -46,8 +62,41 @@ data class EventInstance(
|
||||
val isAllDay: Boolean,
|
||||
val color: Int,
|
||||
val location: String?,
|
||||
/**
|
||||
* Only search results, which read the series master, fill this in — the
|
||||
* Instances query already yields one row per occurrence.
|
||||
*/
|
||||
val isRecurring: Boolean = false,
|
||||
/**
|
||||
* This device user's own answer to the invitation, as far as the grids care
|
||||
* (#180, #230).
|
||||
*/
|
||||
val response: EventResponse = EventResponse.Going,
|
||||
)
|
||||
|
||||
/**
|
||||
* How this device user stands towards an event's invitation
|
||||
* (`Events.SELF_ATTENDEE_STATUS`), reduced to the three cases the calendar
|
||||
* surfaces draw differently (#230).
|
||||
*/
|
||||
enum class EventResponse {
|
||||
/** Your own event, or one you accepted — including a tentative "maybe". */
|
||||
Going,
|
||||
|
||||
/** Invited, no answer given yet: drawn as an outline so it reads as still open. */
|
||||
Invited,
|
||||
|
||||
/**
|
||||
* You answered "no". The event stays on the calendar — it is still an
|
||||
* appointment someone expects an answer about — but every surface strikes it
|
||||
* through, and it plans no reminders (#180).
|
||||
*/
|
||||
Declined,
|
||||
}
|
||||
|
||||
/** Shorthand for the declined case, which most surfaces test on its own (#180). */
|
||||
val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined
|
||||
|
||||
/**
|
||||
* Whether this event has finished relative to [now] — its end is at or before
|
||||
* the current instant. An in-progress event (already started but not yet ended)
|
||||
@@ -56,6 +105,31 @@ data class EventInstance(
|
||||
*/
|
||||
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||
|
||||
/**
|
||||
* The zone this event's calendar dates live in: the device [zone] for timed
|
||||
* events, UTC for all-day ones, whose midnights would otherwise shift day
|
||||
* boundaries (#65, #82). Every surface naming an all-day date goes through here.
|
||||
*/
|
||||
fun EventInstance.dateZone(zone: TimeZone): TimeZone =
|
||||
if (isAllDay) TimeZone.UTC else zone
|
||||
|
||||
/** The first calendar day this event occupies. */
|
||||
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
|
||||
start.toLocalDateTime(dateZone(zone)).date
|
||||
|
||||
/**
|
||||
* The last calendar day this event occupies. An event ending exactly at midnight
|
||||
* does not reach into that day, so resolve just before [EventInstance.end].
|
||||
*/
|
||||
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
||||
val lastInstant = if (end > start) end - 1.milliseconds else start
|
||||
return lastInstant.toLocalDateTime(dateZone(zone)).date
|
||||
}
|
||||
|
||||
/** Whether this event occupies more than one calendar day in [zone]. */
|
||||
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
||||
spanFirstDay(zone) != spanLastDay(zone)
|
||||
|
||||
data class EventDetail(
|
||||
val instance: EventInstance,
|
||||
val description: String?,
|
||||
@@ -83,6 +157,12 @@ data class EventDetail(
|
||||
val eventColor: Int? = null,
|
||||
/** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */
|
||||
val eventColorKey: String? = null,
|
||||
/**
|
||||
* True when this row is a modified occurrence of a series (`ORIGINAL_ID` is
|
||||
* set) rather than a master, so a reschedule takes the plain whole-row path
|
||||
* whatever [rrule] a sync adapter left on it.
|
||||
*/
|
||||
val isException: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -172,6 +252,14 @@ enum class RecurringWriteScope {
|
||||
enum class FailureReason {
|
||||
PermissionRevoked,
|
||||
NoCalendarsConfigured,
|
||||
AllCalendarsHidden,
|
||||
|
||||
/**
|
||||
* Calendars exist and are switched on, but none can receive an event: every
|
||||
* one is read-only, app-managed, or not synced to this device. Distinct from
|
||||
* [AllCalendarsHidden], which a visibility switch fixes.
|
||||
*/
|
||||
NoImportTarget,
|
||||
ProviderUnavailable,
|
||||
EventNotFound,
|
||||
Unknown,
|
||||
|
||||
@@ -138,7 +138,7 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()):
|
||||
}
|
||||
}
|
||||
|
||||
private val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
||||
internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
||||
DayOfWeek.MONDAY to "MO",
|
||||
DayOfWeek.TUESDAY to "TU",
|
||||
DayOfWeek.WEDNESDAY to "WE",
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.plus
|
||||
|
||||
/**
|
||||
* The first [limit] dates a [SimpleRecurrence] fires on, starting at [start]
|
||||
* (DTSTART), for previewing a rule as dates instead of as words. A preview only,
|
||||
* kept to the shapes the picker can build; the provider stays the authority.
|
||||
*
|
||||
* Mirrors RFC 5545: [start] is always the first occurrence (§3.8.5.3), even when
|
||||
* the rule's own picks miss it; a monthly or yearly rule *skips* a period the
|
||||
* start day doesn't exist in rather than clamping; a weekly rule repeats in
|
||||
* blocks of `interval` weeks beginning on Monday (the default WKST, since
|
||||
* [toRRule] never writes one); [RecurrenceEnd.Count] counts real occurrences and
|
||||
* [RecurrenceEnd.Until] is inclusive.
|
||||
*
|
||||
* Returns fewer than [limit] dates when the series ends first, and an empty list
|
||||
* only when the rule yields nothing at all (an UNTIL before [start]).
|
||||
*/
|
||||
fun SimpleRecurrence.upcomingOccurrences(start: LocalDate, limit: Int): List<LocalDate> {
|
||||
if (limit <= 0) return emptyList()
|
||||
val until = (end as? RecurrenceEnd.Until)?.date
|
||||
val maxCount = (end as? RecurrenceEnd.Count)?.times ?: Int.MAX_VALUE
|
||||
val wanted = minOf(limit, maxCount)
|
||||
if (wanted <= 0) return emptyList()
|
||||
if (until != null && start > until) return emptyList()
|
||||
|
||||
// DTSTART is in the recurrence set whatever the rule picks, so seed with it
|
||||
// and let the walk skip anything landing on or before it.
|
||||
val result = mutableListOf(start)
|
||||
var period = 0
|
||||
// Periods can yield nothing (a skipped 31st), so the cap counts periods
|
||||
// examined rather than dates found.
|
||||
while (result.size < wanted && period < MAX_PERIODS) {
|
||||
for (date in occurrencesInPeriod(period, start)) {
|
||||
if (date <= start) continue
|
||||
if (until != null && date > until) return result
|
||||
result += date
|
||||
if (result.size == wanted) return result
|
||||
}
|
||||
period++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** The dates this rule's [period]-th repetition yields (empty when skipped). */
|
||||
private fun SimpleRecurrence.occurrencesInPeriod(period: Int, start: LocalDate): List<LocalDate> =
|
||||
when (freq) {
|
||||
RecurrenceFreq.Daily -> listOf(start.plus(period * interval, DateTimeUnit.DAY))
|
||||
RecurrenceFreq.Weekly -> weeklyOccurrences(period, start)
|
||||
RecurrenceFreq.Monthly -> {
|
||||
val month = start.plus(period * interval, DateTimeUnit.MONTH)
|
||||
// plus() clamps into the shorter month, but the rule skips such a
|
||||
// period — so a clamped date means "not this month".
|
||||
listOfNotNull(dateOrNull(month.year, month.month.number, start.day))
|
||||
}
|
||||
RecurrenceFreq.Yearly ->
|
||||
listOfNotNull(dateOrNull(start.year + period * interval, start.month.number, start.day))
|
||||
}
|
||||
|
||||
/**
|
||||
* One weekly block: every picked weekday inside the week that begins
|
||||
* `period * interval` weeks after the start's own week, in weekday order. With
|
||||
* no picks the rule simply repeats the start's weekday.
|
||||
*/
|
||||
private fun SimpleRecurrence.weeklyOccurrences(period: Int, start: LocalDate): List<LocalDate> {
|
||||
if (byDays.isEmpty()) return listOf(start.plus(period * interval, DateTimeUnit.WEEK))
|
||||
val daysIntoWeek = (start.dayOfWeek.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) %
|
||||
DAYS_PER_WEEK
|
||||
val weekStart = start
|
||||
.minus(daysIntoWeek, DateTimeUnit.DAY)
|
||||
.plus(period * interval, DateTimeUnit.WEEK)
|
||||
return byDays.sortedBy { it.isoDayNumber }.map { day ->
|
||||
weekStart.plus(
|
||||
(day.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) % DAYS_PER_WEEK,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a run of [occurrences] starting at [start] leaves its starting year,
|
||||
* i.e. whether showing them without a year would be ambiguous — a yearly rule
|
||||
* would otherwise read as the same date repeated.
|
||||
*/
|
||||
fun occurrencesSpanYears(occurrences: List<LocalDate>, start: LocalDate): Boolean =
|
||||
occurrences.any { it.year != start.year } || occurrences.map { it.year }.distinct().size > 1
|
||||
|
||||
/** [LocalDate] for a day-of-month that may not exist in that month; null if it doesn't. */
|
||||
private fun dateOrNull(year: Int, month: Int, day: Int): LocalDate? =
|
||||
runCatching { LocalDate(year, month, day) }.getOrNull()
|
||||
|
||||
private const val DAYS_PER_WEEK = 7
|
||||
|
||||
/**
|
||||
* How many repetitions to examine before giving up: generous enough for the
|
||||
* sparsest rule the picker can build, bounded so a rule whose occurrences all
|
||||
* fall outside its own UNTIL can't spin.
|
||||
*/
|
||||
private const val MAX_PERIODS = 2_000
|
||||
@@ -0,0 +1,48 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* [rrule] re-anchored from an occurrence on [oldStart] to one on [newStart].
|
||||
*
|
||||
* `Events.RRULE` is written verbatim while DTSTART moves, so `FREQ=WEEKLY;BYDAY=MO`
|
||||
* would keep naming Monday after the anchor became a Wednesday and the series
|
||||
* would not move at all — `BYDAY` is re-derived from [newStart].
|
||||
*
|
||||
* Only weekly `BYDAY` is realigned, and only for a whole-day move: the rule has
|
||||
* to agree with the series *anchor*, and weekday arithmetic is the only kind
|
||||
* that survives the same wall-clock shift unchanged. Everything else returns
|
||||
* null, as do rules one moved occurrence can't resolve (`BYDAY=MO,WE`, `2TH`,
|
||||
* `BYSETPOS`). The accepted parts are a subset of [parseSimpleRecurrence], so
|
||||
* anything realignable is also a rule [problems] can check the `UNTIL` of.
|
||||
*/
|
||||
fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
|
||||
if (oldStart == newStart) return rrule
|
||||
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else ""
|
||||
val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }
|
||||
if (parts.isEmpty()) return null
|
||||
var weekly = false
|
||||
val rebuilt = parts.map { part ->
|
||||
val eq = part.indexOf('=')
|
||||
if (eq <= 0) return null
|
||||
val key = part.substring(0, eq).uppercase()
|
||||
val value = part.substring(eq + 1).trim()
|
||||
when (key) {
|
||||
"FREQ" -> {
|
||||
weekly = value.equals("WEEKLY", ignoreCase = true)
|
||||
part
|
||||
}
|
||||
"BYDAY" -> {
|
||||
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
|
||||
if (!value.equals(old, ignoreCase = true)) return null
|
||||
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
|
||||
}
|
||||
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
// BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence.
|
||||
if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null
|
||||
if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null
|
||||
return prefix + rebuilt.joinToString(";")
|
||||
}
|
||||
@@ -0,0 +1,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,80 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
/** Opaque alpha, forced onto any colour that arrives without one. */
|
||||
private const val OPAQUE_ALPHA = 0xFF000000.toInt()
|
||||
|
||||
/**
|
||||
* Resolve a colour value out of a foreign `.ics` to an opaque ARGB int.
|
||||
*
|
||||
* Three encodings are in the wild and all appear in the files Calendula is
|
||||
* asked to import:
|
||||
* - a CSS3 colour name, which is what RFC 7986 `COLOR` is defined as
|
||||
* (`COLOR:tomato`);
|
||||
* - a raw signed Android colour int, which the Simple Calendar / Fossify
|
||||
* family writes in its `X-…-COLOR` extensions (`X-FOSSIFY-EVENT-COLOR:-2818048`);
|
||||
* - `#rrggbb` / `#aarrggbb` hex, used by assorted other producers.
|
||||
*
|
||||
* Anything else — including the literal `null` Fossify emits when its calendar
|
||||
* lookup misses — resolves to null.
|
||||
*/
|
||||
fun parseIcsColorValue(raw: String?): Int? {
|
||||
val value = raw?.trim().orEmpty()
|
||||
if (value.isEmpty()) return null
|
||||
|
||||
if (value.startsWith("#")) {
|
||||
val hex = value.substring(1)
|
||||
val parsed = hex.toLongOrNull(16)?.toInt() ?: return null
|
||||
return when (hex.length) {
|
||||
6 -> parsed or OPAQUE_ALPHA
|
||||
8 -> parsed
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
value.toIntOrNull()?.let { return if (it ushr 24 == 0) it or OPAQUE_ALPHA else it }
|
||||
return cssColors[value.lowercase()]
|
||||
}
|
||||
|
||||
/** The CSS3 extended colour keywords, the value space of RFC 7986 `COLOR`. */
|
||||
private val cssColors: Map<String, Int> by lazy {
|
||||
CSS3_TABLE.split(' ').associate { entry ->
|
||||
val name = entry.substringBefore('=')
|
||||
name to (entry.substringAfter('=').toInt(16) or OPAQUE_ALPHA)
|
||||
}
|
||||
}
|
||||
|
||||
private const val CSS3_TABLE =
|
||||
"aliceblue=f0f8ff antiquewhite=faebd7 aqua=00ffff aquamarine=7fffd4 " +
|
||||
"azure=f0ffff beige=f5f5dc bisque=ffe4c4 black=000000 blanchedalmond=ffebcd " +
|
||||
"blue=0000ff blueviolet=8a2be2 brown=a52a2a burlywood=deb887 cadetblue=5f9ea0 " +
|
||||
"chartreuse=7fff00 chocolate=d2691e coral=ff7f50 cornflowerblue=6495ed " +
|
||||
"cornsilk=fff8dc crimson=dc143c cyan=00ffff darkblue=00008b darkcyan=008b8b " +
|
||||
"darkgoldenrod=b8860b darkgray=a9a9a9 darkgreen=006400 darkgrey=a9a9a9 " +
|
||||
"darkkhaki=bdb76b darkmagenta=8b008b darkolivegreen=556b2f darkorange=ff8c00 " +
|
||||
"darkorchid=9932cc darkred=8b0000 darksalmon=e9967a darkseagreen=8fbc8f " +
|
||||
"darkslateblue=483d8b darkslategray=2f4f4f darkslategrey=2f4f4f darkturquoise=00ced1 " +
|
||||
"darkviolet=9400d3 deeppink=ff1493 deepskyblue=00bfff dimgray=696969 " +
|
||||
"dimgrey=696969 dodgerblue=1e90ff firebrick=b22222 floralwhite=fffaf0 " +
|
||||
"forestgreen=228b22 fuchsia=ff00ff gainsboro=dcdcdc ghostwhite=f8f8ff " +
|
||||
"gold=ffd700 goldenrod=daa520 gray=808080 green=008000 greenyellow=adff2f " +
|
||||
"grey=808080 honeydew=f0fff0 hotpink=ff69b4 indianred=cd5c5c indigo=4b0082 " +
|
||||
"ivory=fffff0 khaki=f0e68c lavender=e6e6fa lavenderblush=fff0f5 lawngreen=7cfc00 " +
|
||||
"lemonchiffon=fffacd lightblue=add8e6 lightcoral=f08080 lightcyan=e0ffff " +
|
||||
"lightgoldenrodyellow=fafad2 lightgray=d3d3d3 lightgreen=90ee90 lightgrey=d3d3d3 " +
|
||||
"lightpink=ffb6c1 lightsalmon=ffa07a lightseagreen=20b2aa lightskyblue=87cefa " +
|
||||
"lightslategray=778899 lightslategrey=778899 lightsteelblue=b0c4de " +
|
||||
"lightyellow=ffffe0 lime=00ff00 limegreen=32cd32 linen=faf0e6 magenta=ff00ff " +
|
||||
"maroon=800000 mediumaquamarine=66cdaa mediumblue=0000cd mediumorchid=ba55d3 " +
|
||||
"mediumpurple=9370db mediumseagreen=3cb371 mediumslateblue=7b68ee " +
|
||||
"mediumspringgreen=00fa9a mediumturquoise=48d1cc mediumvioletred=c71585 " +
|
||||
"midnightblue=191970 mintcream=f5fffa mistyrose=ffe4e1 moccasin=ffe4b5 " +
|
||||
"navajowhite=ffdead navy=000080 oldlace=fdf5e6 olive=808000 olivedrab=6b8e23 " +
|
||||
"orange=ffa500 orangered=ff4500 orchid=da70d6 palegoldenrod=eee8aa " +
|
||||
"palegreen=98fb98 paleturquoise=afeeee palevioletred=db7093 papayawhip=ffefd5 " +
|
||||
"peachpuff=ffdab9 peru=cd853f pink=ffc0cb plum=dda0dd powderblue=b0e0e6 " +
|
||||
"purple=800080 rebeccapurple=663399 red=ff0000 rosybrown=bc8f8f royalblue=4169e1 " +
|
||||
"saddlebrown=8b4513 salmon=fa8072 sandybrown=f4a460 seagreen=2e8b57 " +
|
||||
"seashell=fff5ee sienna=a0522d silver=c0c0c0 skyblue=87ceeb slateblue=6a5acd " +
|
||||
"slategray=708090 slategrey=708090 snow=fffafa springgreen=00ff7f " +
|
||||
"steelblue=4682b4 tan=d2b48c teal=008080 thistle=d8bfd8 tomato=ff6347 " +
|
||||
"turquoise=40e0d0 violet=ee82ee wheat=f5deb3 white=ffffff whitesmoke=f5f5f5 " +
|
||||
"yellow=ffff00 yellowgreen=9acd32"
|
||||
@@ -22,6 +22,12 @@ data class IcsEvent(
|
||||
val zoneId: String,
|
||||
/** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */
|
||||
val recurrenceRule: String? = null,
|
||||
/**
|
||||
* Occurrences deleted from the series, in the provider's `EXDATE` shape —
|
||||
* `yyyyMMdd` for an all-day series, a UTC `yyyyMMddTHHmmssZ` stamp
|
||||
* otherwise. Empty for a one-off event.
|
||||
*/
|
||||
val exDates: List<String> = emptyList(),
|
||||
val location: String? = null,
|
||||
val description: String? = null,
|
||||
/** Reminder lead times in minutes before start (raw provider offsets). */
|
||||
|
||||
@@ -7,14 +7,25 @@ import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.math.ceil
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Milliseconds in a calendar day; all-day times are UTC midnights, so this is exact. */
|
||||
private const val DAY_MILLIS = 86_400_000L
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
|
||||
/**
|
||||
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
|
||||
* but [uid] is nullable (an incoming event may carry none; the insert layer
|
||||
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
|
||||
* how the writer encoded them.
|
||||
*
|
||||
* [reminderMinutes] holds raw lead times as the file's `VALARM`s expressed
|
||||
* them — minutes *before* start, so a negative entry means "after start".
|
||||
* [semanticReminderMinutes] turns those into the lead times Calendula models.
|
||||
*/
|
||||
data class ParsedIcsEvent(
|
||||
val uid: String?,
|
||||
@@ -30,8 +41,45 @@ data class ParsedIcsEvent(
|
||||
val status: EventStatus = EventStatus.Confirmed,
|
||||
val availability: Availability = Availability.Busy,
|
||||
val calendarName: String? = null,
|
||||
/** Opaque ARGB the source file gave this event, if any (see [parseIcsColorValue]). */
|
||||
val color: Int? = null,
|
||||
/** Excluded occurrences, already in the provider's `EXDATE` shape. */
|
||||
val exDates: List<String> = emptyList(),
|
||||
/** True when this came from a `VTODO` rather than a `VEVENT`. */
|
||||
val isTask: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* [reminderMinutes] mapped onto Calendula's model: a lead time in minutes before
|
||||
* the event, never negative.
|
||||
*
|
||||
* For a timed event the raw lead time already is that, and a trigger that fires
|
||||
* *after* the start models no lead time at all — it is dropped rather than
|
||||
* clamped, which would invent a reminder at the start the file never asked for
|
||||
* (a legal `TRIGGER:PT30M` follow-up alarm is the case). All-day reminders are
|
||||
* whole days before the event, fired at the user's configured time of day (see
|
||||
* `AllDayReminderEncoding`), so a raw offset counts the whole days its trigger
|
||||
* lands *earlier than* the event's UTC midnight — i.e. it rounds **up**.
|
||||
*
|
||||
* Rounding to the nearest day instead would lose a day for every producer whose
|
||||
* all-day notifications fire after noon: a file's raw offset is
|
||||
* `days × 1440 − timeOfDay`, so "1 day before at 18:00" arrives as
|
||||
* `TRIGGER:-PT6H` (raw 360) and would read as "on the day". Rounding up also
|
||||
* lands Fossify's encoding correctly — it writes "on the day at 09:00" as a
|
||||
* *positive* `TRIGGER:P0DT9H0M0S`, nine hours after the UTC midnight, which
|
||||
* ceils to zero days before.
|
||||
*/
|
||||
fun ParsedIcsEvent.semanticReminderMinutes(): List<Int> = reminderMinutes
|
||||
.mapNotNull { raw ->
|
||||
if (isAllDay) {
|
||||
ceil(raw.toDouble() / MINUTES_PER_DAY).toInt().coerceAtLeast(0) * MINUTES_PER_DAY
|
||||
} else {
|
||||
raw.takeIf { it >= 0 }
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
.sorted()
|
||||
|
||||
/** Things the parser dropped rather than failing — surfaced in the import report. */
|
||||
enum class IcsParseWarning {
|
||||
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
|
||||
@@ -45,6 +93,12 @@ enum class IcsParseWarning {
|
||||
|
||||
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
|
||||
UnknownTimezone,
|
||||
|
||||
/** `VTODO` components were imported as events — Calendula has no task model. */
|
||||
TasksImportedAsEvents,
|
||||
|
||||
/** A malformed `RRULE` was repaired or dropped (see [sanitizeRrule]). */
|
||||
RecurrenceRuleRepaired,
|
||||
}
|
||||
|
||||
data class IcsParseResult(
|
||||
@@ -52,8 +106,28 @@ data class IcsParseResult(
|
||||
val warnings: Set<IcsParseWarning>,
|
||||
)
|
||||
|
||||
/** Outcome of a bulk `.ics` import into one calendar. */
|
||||
data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
|
||||
/**
|
||||
* Outcome of a bulk `.ics` import into one calendar. [failed] counts events the
|
||||
* provider rejected — the import continues past them, so a single unusable
|
||||
* event can't cost the user the rest of the file.
|
||||
*
|
||||
* [notAttempted] counts what a retry still has to import, because the permission
|
||||
* or the target calendar went away mid-file and every remaining insert would have
|
||||
* failed the same way. Events past that point whose UID is already in the
|
||||
* calendar count under [skippedDuplicate] instead — they were never going to be
|
||||
* added either way. It travels in the summary rather than as an exception so the
|
||||
* counts of what *did* land survive: an import is only idempotent for events that
|
||||
* carry a UID, so a retry told "nothing happened" would duplicate the rest.
|
||||
*/
|
||||
data class IcsImportSummary(
|
||||
val imported: Int,
|
||||
val skippedDuplicate: Int,
|
||||
val failed: Int = 0,
|
||||
val notAttempted: Int = 0,
|
||||
)
|
||||
|
||||
/** The non-standard property most exporters name the whole calendar with. */
|
||||
private const val CALENDAR_NAME_PROPERTY = "X-WR-CALNAME"
|
||||
|
||||
/**
|
||||
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
|
||||
@@ -62,6 +136,9 @@ data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
|
||||
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
|
||||
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
|
||||
* resolved against the OS tz database instead ([deviceZone] is the fallback).
|
||||
*
|
||||
* `VTODO` components are read as all-day/timed events: Calendula models no
|
||||
* tasks, and dropping them would silently lose half of some exports.
|
||||
*/
|
||||
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
|
||||
|
||||
@@ -69,42 +146,62 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
val lines = unfoldLines(text)
|
||||
val events = mutableListOf<ParsedIcsEvent>()
|
||||
val warnings = mutableSetOf<IcsParseWarning>()
|
||||
var calendarName: String? = null
|
||||
// Scanned up front, not in document order: an X-WR-CALNAME after the
|
||||
// first VEVENT still names the calendar. The cheap prefix test first,
|
||||
// so a file without one doesn't pay a full parse to find nothing.
|
||||
val calendarName = lines.asSequence()
|
||||
.filter { it.startsWith(CALENDAR_NAME_PROPERTY, ignoreCase = true) }
|
||||
.mapNotNull(::parseContentLine)
|
||||
.firstOrNull { it.name == CALENDAR_NAME_PROPERTY }
|
||||
?.let { unescapeText(it.value).trim().ifEmpty { null } }
|
||||
|
||||
var i = 0
|
||||
while (i < lines.size) {
|
||||
val line = parseContentLine(lines[i])
|
||||
if (line == null) { i++; continue }
|
||||
val component = when {
|
||||
line.isBegin("VEVENT") -> "VEVENT"
|
||||
line.isBegin("VTODO") -> "VTODO"
|
||||
else -> null
|
||||
}
|
||||
when {
|
||||
line.isBegin("VEVENT") -> {
|
||||
val end = indexOfEnd(lines, i + 1, "VEVENT")
|
||||
parseVevent(lines.subList(i + 1, end), calendarName, warnings)
|
||||
?.let(events::add)
|
||||
i = end + 1
|
||||
component != null -> {
|
||||
// A component whose END line never arrives ends at the next
|
||||
// one's BEGIN, not at that one's END: reading to the far END
|
||||
// would fold two events into one body, where the later
|
||||
// properties overwrite the earlier and one event is lost.
|
||||
val end = indexOfEnd(lines, i + 1, component)
|
||||
val body = indexOfNextComponent(lines, i + 1, end)
|
||||
parseComponent(
|
||||
body = lines.subList(i + 1, body),
|
||||
fileCalendarName = calendarName,
|
||||
warnings = warnings,
|
||||
isTask = component == "VTODO",
|
||||
)?.let(events::add)
|
||||
i = if (body < end) body else end + 1
|
||||
}
|
||||
line.isBegin("VTIMEZONE") -> {
|
||||
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
||||
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
|
||||
}
|
||||
line.name == "X-WR-CALNAME" -> {
|
||||
calendarName = unescapeText(line.value).trim().ifEmpty { null }
|
||||
i++
|
||||
}
|
||||
else -> i++
|
||||
}
|
||||
}
|
||||
if (events.any { it.isTask }) warnings.add(IcsParseWarning.TasksImportedAsEvents)
|
||||
return IcsParseResult(events, warnings)
|
||||
}
|
||||
|
||||
private fun parseVevent(
|
||||
private fun parseComponent(
|
||||
body: List<String>,
|
||||
fileCalendarName: String?,
|
||||
warnings: MutableSet<IcsParseWarning>,
|
||||
isTask: Boolean,
|
||||
): ParsedIcsEvent? {
|
||||
var uid: String? = null
|
||||
var summary = ""
|
||||
var dtStart: IcsDateTime? = null
|
||||
var dtEnd: IcsDateTime? = null
|
||||
var due: IcsDateTime? = null
|
||||
var duration: String? = null
|
||||
var rrule: String? = null
|
||||
var location: String? = null
|
||||
@@ -112,7 +209,11 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
var status = EventStatus.Confirmed
|
||||
var availability = Availability.Busy
|
||||
var calendarName = fileCalendarName
|
||||
var category: String? = null
|
||||
var eventColor: Int? = null
|
||||
var calendarColor: Int? = null
|
||||
val reminders = mutableListOf<Int>()
|
||||
val exDateLines = mutableListOf<IcsContentLine>()
|
||||
var skipAsOverride = false
|
||||
|
||||
var i = 0
|
||||
@@ -129,9 +230,11 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
"UID" -> uid = line.value.trim().ifEmpty { null }
|
||||
"SUMMARY" -> summary = unescapeText(line.value)
|
||||
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
|
||||
"DUE" -> if (isTask) due = parseIcsDateTime(line, warnings)
|
||||
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
|
||||
"DURATION" -> duration = line.value.trim()
|
||||
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
|
||||
"EXDATE" -> exDateLines.add(line)
|
||||
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
|
||||
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
|
||||
"STATUS" -> status = mapIcsStatus(line.value)
|
||||
@@ -142,6 +245,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
|
||||
"X-CALENDULA-CALENDAR" ->
|
||||
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
|
||||
// Fossify names the source calendar per event; it never writes
|
||||
// an X-WR-CALNAME, so this is the only calendar label its files
|
||||
// carry. Elsewhere CATEGORIES is a plain tag list, so it only
|
||||
// stands in where nothing named the calendar outright.
|
||||
"CATEGORIES" -> category = singleCategory(line.value) ?: category
|
||||
// Per-event colour, most specific encoding first (see parseIcsColorValue).
|
||||
"X-FOSSIFY-EVENT-COLOR", "X-SMT-EVENT-COLOR" ->
|
||||
eventColor = parseIcsColorValue(line.value) ?: eventColor
|
||||
"COLOR" -> eventColor = eventColor ?: parseIcsColorValue(line.value)
|
||||
// The *calendar's* colour. Kept as a fallback so a migration that
|
||||
// folds several source calendars into one keeps them apart visually.
|
||||
"X-FOSSIFY-CATEGORY-COLOR", "X-SMT-CATEGORY-COLOR", "CATEGORY_COLOR" ->
|
||||
calendarColor = parseIcsColorValue(line.value) ?: calendarColor
|
||||
}
|
||||
i++
|
||||
}
|
||||
@@ -150,37 +266,125 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped)
|
||||
return null
|
||||
}
|
||||
// Most producers give a VTODO only a DUE, which is then the moment the
|
||||
// task sits at. One that carries a DTSTART too means the span between
|
||||
// them, so DUE stands in for the end it has no DTEND for. Resolved after
|
||||
// the sweep rather than inside it: property order isn't guaranteed, and
|
||||
// a DUE ahead of the DTSTART would otherwise be taken for the start.
|
||||
if (isTask && due != null) {
|
||||
if (dtStart == null) dtStart = due else if (dtEnd == null) dtEnd = due
|
||||
}
|
||||
val start = dtStart ?: run {
|
||||
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
|
||||
return null
|
||||
}
|
||||
val end = dtEnd
|
||||
?: duration?.let {
|
||||
start.copy(
|
||||
instant = Instant.fromEpochMilliseconds(
|
||||
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
||||
),
|
||||
)
|
||||
}
|
||||
?: start
|
||||
val recurrence = sanitizeRrule(rrule)
|
||||
if (recurrence.repaired) warnings.add(IcsParseWarning.RecurrenceRuleRepaired)
|
||||
val end = resolveEnd(start, dtEnd, duration)
|
||||
return ParsedIcsEvent(
|
||||
uid = uid,
|
||||
summary = summary,
|
||||
start = start.instant,
|
||||
end = end.instant,
|
||||
end = end,
|
||||
isAllDay = start.isAllDay,
|
||||
zoneId = start.zoneId,
|
||||
recurrenceRule = rrule,
|
||||
recurrenceRule = recurrence.rule,
|
||||
location = location,
|
||||
description = description,
|
||||
reminderMinutes = reminders.distinct(),
|
||||
status = status,
|
||||
availability = availability,
|
||||
calendarName = calendarName,
|
||||
calendarName = calendarName ?: category,
|
||||
color = eventColor ?: calendarColor,
|
||||
exDates = if (recurrence.rule == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
normalizeExDates(exDateLines, start)
|
||||
},
|
||||
isTask = isTask,
|
||||
)
|
||||
}
|
||||
|
||||
/** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
|
||||
/**
|
||||
* The event's end instant.
|
||||
*
|
||||
* All-day events get two RFC 5545 §3.6.1 rules applied that a literal read
|
||||
* would miss: a `DATE`-valued `DTSTART` with no `DTEND` lasts one day, and —
|
||||
* since the provider expands a zero-length all-day series into no instances
|
||||
* at all — an all-day event may never end at or before it starts.
|
||||
*/
|
||||
private fun resolveEnd(
|
||||
start: IcsDateTime,
|
||||
dtEnd: IcsDateTime?,
|
||||
duration: String?,
|
||||
): Instant {
|
||||
val explicit = dtEnd?.instant ?: duration?.let {
|
||||
Instant.fromEpochMilliseconds(
|
||||
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
||||
)
|
||||
}
|
||||
if (!start.isAllDay) return explicit ?: start.instant
|
||||
return explicit?.takeIf { it > start.instant } ?: start.instant.plusDays(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* `EXDATE` values in the shape `Events.EXDATE` wants: `yyyyMMdd` for an
|
||||
* all-day series, a UTC `yyyyMMddTHHmmssZ` stamp otherwise.
|
||||
*
|
||||
* Fossify stores its excluded occurrences as bare day codes and writes them
|
||||
* out that way even for timed series, where the property is then ambiguous.
|
||||
* Such a value is resolved against the series' own time of day, which is
|
||||
* what it meant — its repetitions never move within the day.
|
||||
*
|
||||
* An all-day exclusion names a calendar day, so a zone-qualified DATE-TIME
|
||||
* still means the day it spells out; resolving it to an instant first and
|
||||
* reading *that* back in UTC would roll a midnight east of UTC onto the day
|
||||
* before and exclude an occurrence that doesn't exist.
|
||||
*/
|
||||
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
|
||||
if (lines.isEmpty()) return emptyList()
|
||||
val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone
|
||||
val startTime = start.instant.toLocalDateTime(startZone).time
|
||||
return lines
|
||||
.flatMap { line -> line.value.split(',').map { line to it.trim() } }
|
||||
.mapNotNull { (line, token) ->
|
||||
when {
|
||||
token.isEmpty() -> null
|
||||
start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode)
|
||||
token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp)
|
||||
else -> parseBasicDate(token)
|
||||
?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) }
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
}
|
||||
|
||||
/**
|
||||
* [startZone] is the series' own zone, which a floating value (no `Z`, no
|
||||
* `TZID`) has to be read in — RFC 5545 ties `EXDATE` to `DTSTART`'s form,
|
||||
* and reading it in the device's zone instead would put the exclusion on an
|
||||
* instant no occurrence has.
|
||||
*/
|
||||
private fun parseExDateTime(
|
||||
token: String,
|
||||
line: IcsContentLine,
|
||||
startZone: TimeZone,
|
||||
): Instant? {
|
||||
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
|
||||
val zone = when {
|
||||
token.endsWith("Z") -> TimeZone.UTC
|
||||
else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() }
|
||||
?: startZone
|
||||
}
|
||||
return ldt.toInstant(zone)
|
||||
}
|
||||
|
||||
/**
|
||||
* A `VALARM`'s offset from the event start, in minutes *before* it — so a
|
||||
* trigger that fires after the start (which is how the Fossify family
|
||||
* encodes "on the day at 09:00") comes back negative. Null when the trigger
|
||||
* isn't a usable relative offset.
|
||||
*/
|
||||
private fun parseAlarmMinutes(body: List<String>): Int? {
|
||||
val trigger = body.asSequence()
|
||||
.mapNotNull { parseContentLine(it) }
|
||||
@@ -189,8 +393,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
|
||||
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
|
||||
val millis = parseRfc2445DurationMillis(trigger.value)
|
||||
// Negative = before start (the normal case) → positive lead minutes.
|
||||
return (-millis / 60_000L).toInt().coerceAtLeast(0)
|
||||
return (-millis / 60_000L).toInt()
|
||||
}
|
||||
|
||||
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
|
||||
@@ -218,7 +421,52 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
fun IcsContentLine.isBegin(component: String) =
|
||||
name == "BEGIN" && value.trim().equals(component, true)
|
||||
|
||||
fun Instant.plusDays(days: Int): Instant =
|
||||
Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS)
|
||||
|
||||
fun dayCode(date: LocalDate): String =
|
||||
"%04d%02d%02d".format(date.year, date.month.number, date.day)
|
||||
|
||||
fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
|
||||
"%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second)
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-valued `CATEGORIES`, which is what the Fossify family writes
|
||||
* for the source calendar's name. A multi-item list is a tag list and
|
||||
* names no calendar, so it is ignored.
|
||||
*/
|
||||
fun singleCategory(raw: String): String? {
|
||||
var escaped = false
|
||||
for (c in raw) {
|
||||
when {
|
||||
escaped -> escaped = false
|
||||
c == '\\' -> escaped = true
|
||||
c == ',' -> return null
|
||||
}
|
||||
}
|
||||
return unescapeText(raw).trim()
|
||||
.takeIf { it.isNotEmpty() && !it.equals("null", true) }
|
||||
}
|
||||
|
||||
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||
/**
|
||||
* Where an unterminated component's body has to stop: the next top-level
|
||||
* `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A
|
||||
* nested `VALARM` is part of the body and is not a boundary.
|
||||
*/
|
||||
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int {
|
||||
var i = from
|
||||
while (i < end) {
|
||||
val line = parseContentLine(lines[i])
|
||||
if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) {
|
||||
return i
|
||||
}
|
||||
i++
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||
var i = from
|
||||
while (i < lines.size) {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
private val VALID_FREQ = setOf(
|
||||
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
|
||||
)
|
||||
|
||||
private val VALID_WEEKDAYS = setOf("SU", "MO", "TU", "WE", "TH", "FR", "SA")
|
||||
|
||||
/**
|
||||
* What `EventRecurrence` accepts in each numeric list part: a value range, plus
|
||||
* whether `0` is legal. Its `parseNumberList` throws on a non-number, on an item
|
||||
* outside the range *and* on a zero in the by-position parts, so checking only
|
||||
* that an item is a number would still hand the provider a rule it rejects.
|
||||
*
|
||||
* `BYSETPOS` is the one part it leaves unbounded; `0` is dropped from it anyway
|
||||
* because RFC 5545 §3.3.10 gives it no meaning.
|
||||
*/
|
||||
private val NUMERIC_LIST_PARTS = mapOf(
|
||||
"BYSECOND" to NumericPart(0..59, zeroAllowed = true),
|
||||
"BYMINUTE" to NumericPart(0..59, zeroAllowed = true),
|
||||
"BYHOUR" to NumericPart(0..23, zeroAllowed = true),
|
||||
"BYMONTHDAY" to NumericPart(-31..31, zeroAllowed = false),
|
||||
"BYYEARDAY" to NumericPart(-366..366, zeroAllowed = false),
|
||||
"BYWEEKNO" to NumericPart(-53..53, zeroAllowed = false),
|
||||
"BYMONTH" to NumericPart(1..12, zeroAllowed = false),
|
||||
"BYSETPOS" to NumericPart(Int.MIN_VALUE..Int.MAX_VALUE, zeroAllowed = false),
|
||||
)
|
||||
|
||||
private class NumericPart(private val range: IntRange, private val zeroAllowed: Boolean) {
|
||||
fun accepts(item: String): Boolean {
|
||||
val n = item.toIntOrNull() ?: return false
|
||||
return n in range && (zeroAllowed || n != 0)
|
||||
}
|
||||
}
|
||||
|
||||
/** `UNTIL` in RFC 5545 basic format; anything else fails at expansion time. */
|
||||
private val UNTIL_SHAPE = Regex("""\d{8}(T\d{6}Z?)?""")
|
||||
|
||||
/**
|
||||
* A foreign RRULE made safe to hand `CalendarContract`.
|
||||
*
|
||||
* [rule] is what survived, or null when there is nothing left to salvage.
|
||||
* [repaired] is true only when a part was actually dropped — pure normalisation
|
||||
* (case, a stray separator) doesn't count, so a conformant file is never
|
||||
* reported to the user as faulty.
|
||||
*/
|
||||
data class SanitizedRrule(val rule: String?, val repaired: Boolean)
|
||||
|
||||
/**
|
||||
* Salvage a foreign RRULE.
|
||||
*
|
||||
* The provider validates every RRULE through `EventRecurrence.parse`, which
|
||||
* **throws** on a malformed rule — and the throw surfaces from `insert`, not
|
||||
* from a later read. One bad rule in an imported file would therefore abort the
|
||||
* write it rides on. Producers do emit such rules: Fossify's exporter writes a
|
||||
* bare `;BYDAY=` whenever a weekly event carries no weekday mask
|
||||
* (`Parser.getByDay` interpolates an empty day string unconditionally), which is
|
||||
* the state every weekly event it imported from elsewhere is left in.
|
||||
*
|
||||
* That parser has a fixed table of parts and throws on any name outside it, so
|
||||
* this is a whitelist: an unrecognised part (RFC 7529 `RSCALE`, a vendor `X-`
|
||||
* extension) and an unusable value are dropped rather than passed on to fail the
|
||||
* whole event. Only a missing or unrecognised `FREQ` — without which there is no
|
||||
* rule at all — gives up entirely.
|
||||
*/
|
||||
fun sanitizeRrule(raw: String?): SanitizedRrule {
|
||||
val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty()
|
||||
if (rule.isEmpty()) return SanitizedRrule(rule = null, repaired = false)
|
||||
|
||||
var freqSeen = false
|
||||
var repaired = false
|
||||
val parts = mutableListOf<String>()
|
||||
|
||||
/** The items [valid] accepts, uppercased and comma-joined; null if none survive. */
|
||||
fun keepItems(value: String, valid: (String) -> Boolean): String? {
|
||||
val items = value.split(',').map { it.trim().uppercase() }
|
||||
val kept = items.filter(valid)
|
||||
if (kept.size != items.size) repaired = true
|
||||
return kept.takeIf { it.isNotEmpty() }?.joinToString(",")
|
||||
}
|
||||
|
||||
for (part in rule.split(';')) {
|
||||
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
|
||||
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
|
||||
// being dropped, so it falls through and marks the rule repaired.
|
||||
if (part.isBlank()) continue
|
||||
val key = part.substringBefore('=', "").trim().uppercase()
|
||||
val value = if ('=' in part) part.substringAfter('=').trim() else ""
|
||||
val kept = when {
|
||||
key.isEmpty() || value.isEmpty() -> null
|
||||
key == "FREQ" ->
|
||||
value.uppercase().takeIf { it in VALID_FREQ }
|
||||
?.also { freqSeen = true }
|
||||
?.let { "FREQ=$it" }
|
||||
// A non-positive or non-numeric INTERVAL/COUNT is rejected by the
|
||||
// provider, and a COUNT of zero would expand to no occurrences.
|
||||
key == "INTERVAL" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "INTERVAL=$it" }
|
||||
key == "COUNT" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "COUNT=$it" }
|
||||
key == "UNTIL" ->
|
||||
value.uppercase().takeIf { UNTIL_SHAPE.matches(it) }?.let { "UNTIL=$it" }
|
||||
key == "WKST" ->
|
||||
value.uppercase().takeIf { it in VALID_WEEKDAYS }?.let { "WKST=$it" }
|
||||
key == "BYDAY" -> keepItems(value, ::isWeekdayItem)?.let { "BYDAY=$it" }
|
||||
key in NUMERIC_LIST_PARTS ->
|
||||
keepItems(value, NUMERIC_LIST_PARTS.getValue(key)::accepts)?.let { "$key=$it" }
|
||||
else -> null
|
||||
}
|
||||
if (kept == null) repaired = true else parts += kept
|
||||
}
|
||||
// `EventRecurrence.parse` ends with *two* global checks, not one: a missing
|
||||
// FREQ throws, and so does an UNTIL that arrives together with a COUNT. A
|
||||
// rule carrying both is malformed per RFC 5545 §3.3.10 but real producers
|
||||
// emit it, and left alone it would throw straight out of `insert` — the very
|
||||
// failure this function exists to prevent. UNTIL is kept: it bounds the
|
||||
// series at an absolute date, so a stale COUNT can't over-generate past it.
|
||||
if (parts.any { it.startsWith("UNTIL=") }) {
|
||||
if (parts.removeAll { it.startsWith("COUNT=") }) repaired = true
|
||||
}
|
||||
return if (freqSeen) {
|
||||
SanitizedRrule(rule = parts.joinToString(";"), repaired = repaired)
|
||||
} else {
|
||||
SanitizedRrule(rule = null, repaired = true)
|
||||
}
|
||||
}
|
||||
|
||||
/** A `BYDAY` item: a two-letter weekday, optionally preceded by an ordinal. */
|
||||
private fun isWeekdayItem(item: String): Boolean {
|
||||
if (item.length < 2) return false
|
||||
val ordinal = item.dropLast(2)
|
||||
return item.takeLast(2) in VALID_WEEKDAYS &&
|
||||
(ordinal.isEmpty() || ordinal.toIntOrNull() != null)
|
||||
}
|
||||
@@ -46,6 +46,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||
appendTimes(event)
|
||||
event.recurrenceRule?.takeIf { it.isNotBlank() }
|
||||
?.let { add("RRULE:${it.removePrefix("RRULE:")}") }
|
||||
appendExDates(event)
|
||||
event.location?.takeIf { it.isNotBlank() }
|
||||
?.let { add("LOCATION:${escapeText(it)}") }
|
||||
event.description?.takeIf { it.isNotBlank() }
|
||||
@@ -83,6 +84,20 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The series' deleted occurrences. RFC 5545 ties `EXDATE`'s value type to
|
||||
* `DTSTART`'s, so an all-day series needs the explicit `VALUE=DATE` — without
|
||||
* it the bare day codes read as an invalid DATE-TIME. Only written for a
|
||||
* recurring event: an exclusion names an occurrence, and a one-off has none.
|
||||
*/
|
||||
private fun MutableList<String>.appendExDates(event: IcsEvent) {
|
||||
if (event.recurrenceRule.isNullOrBlank()) return
|
||||
val stamps = event.exDates.filter { it.isNotBlank() }.distinct()
|
||||
if (stamps.isEmpty()) return
|
||||
val prefix = if (event.isAllDay) "EXDATE;VALUE=DATE:" else "EXDATE:"
|
||||
add(prefix + stamps.joinToString(","))
|
||||
}
|
||||
|
||||
private fun MutableList<String>.appendAlarm(minutes: Int, summary: String) {
|
||||
add("BEGIN:VALARM")
|
||||
add("ACTION:DISPLAY")
|
||||
|
||||
@@ -13,6 +13,11 @@ import kotlinx.datetime.toLocalDateTime
|
||||
* calendar, exactly like a fresh create — the user confirms the target and
|
||||
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
|
||||
* handling (provider all-day times are UTC midnights with an exclusive end).
|
||||
*
|
||||
* [ParsedIcsEvent.color] is deliberately not carried over: picking the calendar
|
||||
* is the first thing this form asks for, and that clears any colour (a raw ARGB
|
||||
* is invalid on a calendar whose account publishes a palette). Bulk import,
|
||||
* where the target is known up front, does keep it.
|
||||
*/
|
||||
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||
val (start, end) = if (isAllDay) {
|
||||
@@ -31,7 +36,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||
end = end,
|
||||
location = location.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
reminders = reminderMinutes.distinct().sorted(),
|
||||
reminders = semanticReminderMinutes(),
|
||||
availability = availability,
|
||||
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package de.jeanlucmakiola.calendula.domain.reminders
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
* Pure decision layer of in-house reminder delivery (#75): instances and reminder
|
||||
* offsets in, fire instants out. No provider, no clock. See docs/ARCHITECTURE.md.
|
||||
*/
|
||||
|
||||
/** An occurrence that reminders can hang off, flattened out of `Instances`. */
|
||||
data class ReminderEventInstance(
|
||||
val eventId: Long,
|
||||
val calendarId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
val title: String,
|
||||
val location: String?,
|
||||
val isAllDay: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* One occurrence paired with one of its reminder offsets, and the instant that
|
||||
* pairing has to fire at.
|
||||
*/
|
||||
data class PlannedReminder(
|
||||
val instance: ReminderEventInstance,
|
||||
val minutes: Int,
|
||||
val alarmMillis: Long,
|
||||
) {
|
||||
/**
|
||||
* Stable identity, keying the notification tag and the snooze/dismiss
|
||||
* `PendingIntent`s. Survives reboot, re-scan and reinstall.
|
||||
*/
|
||||
val key: Long = key(instance.eventId, instance.beginMillis, minutes)
|
||||
|
||||
private companion object {
|
||||
fun key(eventId: Long, beginMillis: Long, minutes: Int): Long {
|
||||
var h = eventId * 1_000_003L
|
||||
h = (h xor beginMillis) * 31L
|
||||
return h + minutes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What one scan concluded: post these now, and wake up again at [nextAlarmMillis]. */
|
||||
data class ReminderSchedule(
|
||||
val due: List<PlannedReminder>,
|
||||
val nextAlarmMillis: Long,
|
||||
)
|
||||
|
||||
private const val MILLIS_PER_MINUTE = 60_000L
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
|
||||
/**
|
||||
* Pair every instance with each of its event's reminder offsets.
|
||||
*
|
||||
* Timed occurrences fire at `begin − minutes`. All-day ones read the offset only
|
||||
* for *which day* it means ([allDayLeadDays]) and take the hour from
|
||||
* [allDayTimeMinutes], recomposed against each occurrence's own date in [zone].
|
||||
* Duplicate offsets in [minutesByEvent] collapse.
|
||||
*
|
||||
* Every offset given is planned. The provider's "use the account default"
|
||||
* sentinel names a lead time no reader can resolve, and is translated out on the
|
||||
* way in
|
||||
* ([de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults]) — taken
|
||||
* as an offset it would arm an alarm a minute after the event began.
|
||||
*/
|
||||
fun planReminders(
|
||||
instances: List<ReminderEventInstance>,
|
||||
minutesByEvent: Map<Long, List<Int>>,
|
||||
zone: ZoneId,
|
||||
allDayTimeMinutes: Int,
|
||||
): List<PlannedReminder> = instances.flatMap { instance ->
|
||||
minutesByEvent[instance.eventId].orEmpty()
|
||||
.distinct()
|
||||
.map { minutes ->
|
||||
PlannedReminder(
|
||||
instance = instance,
|
||||
minutes = minutes,
|
||||
alarmMillis = if (instance.isAllDay) {
|
||||
allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes)
|
||||
} else {
|
||||
instance.beginMillis - minutes * MILLIS_PER_MINUTE
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** UTC midnight of an all-day occurrence, as the calendar date it stands for. */
|
||||
private fun allDayDate(beginMillis: Long): LocalDate =
|
||||
Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
|
||||
/**
|
||||
* How many whole days before its occurrence a raw all-day offset means.
|
||||
*
|
||||
* Our own rows fold the wanted hour into the offset, so the local date of the
|
||||
* encoded instant is the answer. A plain multiple of 1440 is a foreign bare lead
|
||||
* time and taken at face value instead — unless the instant lands on the hour the
|
||||
* setting names (within [NAMED_HOUR_TOLERANCE_MINUTES], for DST drift), where the
|
||||
* encodings collide and the tie goes to our own reading.
|
||||
*
|
||||
* Also used by
|
||||
* [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes] for
|
||||
* display, so screen and notification agree.
|
||||
*/
|
||||
internal fun allDayLeadDays(
|
||||
rawMinutes: Int,
|
||||
startDate: LocalDate,
|
||||
zone: ZoneId,
|
||||
allDayTimeMinutes: Int,
|
||||
): Long {
|
||||
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
val encoded = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE).atZone(zone)
|
||||
val minutesFromNamedHour = encoded.toLocalTime().let {
|
||||
val delta = (it.hour * 60 + it.minute - allDayTimeMinutes).mod(MINUTES_PER_DAY)
|
||||
minOf(delta, MINUTES_PER_DAY - delta)
|
||||
}
|
||||
if (rawMinutes % MINUTES_PER_DAY == 0 && minutesFromNamedHour > NAMED_HOUR_TOLERANCE_MINUTES) {
|
||||
return (rawMinutes / MINUTES_PER_DAY).toLong()
|
||||
}
|
||||
return ChronoUnit.DAYS.between(encoded.toLocalDate(), startDate)
|
||||
}
|
||||
|
||||
/** DST drift a row written in the other phase carries, rounded up past Lord Howe's half hour. */
|
||||
private const val NAMED_HOUR_TOLERANCE_MINUTES = 90
|
||||
|
||||
private fun allDayAlarmMillis(
|
||||
beginMillis: Long,
|
||||
rawMinutes: Int,
|
||||
zone: ZoneId,
|
||||
allDayTimeMinutes: Int,
|
||||
): Long = allDayDate(beginMillis)
|
||||
.minusDays(allDayLeadDays(rawMinutes, allDayDate(beginMillis), zone, allDayTimeMinutes))
|
||||
.atTime(LocalTime.of(allDayTimeMinutes / 60, allDayTimeMinutes % 60))
|
||||
.atZone(zone)
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
|
||||
/**
|
||||
* Split [planned] into what is due now and when to wake up next.
|
||||
*
|
||||
* Due means the fire instant falls in `(lastFiredMillis, nowMillis]`, so a scan
|
||||
* running twice cannot post twice while a late one still catches up. Reminders
|
||||
* whose event has ended are dropped ([isStillRelevant]). [nextAlarmMillis] is
|
||||
* capped at [horizonMillis] so the lookahead window keeps rolling forward.
|
||||
*/
|
||||
fun scheduleReminders(
|
||||
planned: List<PlannedReminder>,
|
||||
lastFiredMillis: Long,
|
||||
nowMillis: Long,
|
||||
horizonMillis: Long,
|
||||
): ReminderSchedule {
|
||||
val due = planned
|
||||
.filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis }
|
||||
.filter { it.instance.isStillRelevant(nowMillis) }
|
||||
.distinctBy { it.key }
|
||||
.sortedWith(compareBy({ it.instance.beginMillis }, { it.key }))
|
||||
val nextPending = planned
|
||||
.filter { it.alarmMillis > nowMillis }
|
||||
.minOfOrNull { it.alarmMillis }
|
||||
return ReminderSchedule(
|
||||
due = due,
|
||||
nextAlarmMillis = minOf(nextPending ?: horizonMillis, horizonMillis),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Still worth showing while the occurrence has not ended. Falls back to the
|
||||
* begin time when the end is unknown (0L).
|
||||
*/
|
||||
fun ReminderEventInstance.isStillRelevant(nowMillis: Long): Boolean =
|
||||
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
|
||||
|
||||
/**
|
||||
* The watermark a scan at [nowMillis] should measure against. A first-ever scan
|
||||
* claims the present rather than replaying everything since the epoch; a
|
||||
* watermark in the future (clock moved back) is clamped so it can't silence
|
||||
* every reminder until real time catches up.
|
||||
*/
|
||||
fun reminderWatermark(lastScanMillis: Long?, nowMillis: Long): Long =
|
||||
lastScanMillis?.coerceAtMost(nowMillis) ?: nowMillis
|
||||
|
||||
/**
|
||||
* How far ahead instances must be queried: the plain lookahead plus the longest
|
||||
* reminder offset, so a "two weeks before" is planned before it comes due. The
|
||||
* stretch is capped at [MAX_REMINDER_LEAD_MILLIS] — `maxReminderMinutes` is the
|
||||
* largest row in the whole provider, and a nonsense one would otherwise make
|
||||
* every scan expand every series over years.
|
||||
*/
|
||||
fun reminderQueryHorizon(lookaheadMillis: Long, maxReminderMinutes: Int): Long =
|
||||
lookaheadMillis + (maxReminderMinutes * MILLIS_PER_MINUTE).coerceIn(0L, MAX_REMINDER_LEAD_MILLIS)
|
||||
|
||||
/** Longest reminder offset a scan stretches its query window for — one year. */
|
||||
const val MAX_REMINDER_LEAD_MILLIS = 365L * 24 * 60 * 60 * 1000
|
||||
@@ -1,52 +1,102 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.SeekableTransitionState
|
||||
import androidx.compose.animation.core.rememberTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.jeanlucmakiola.calendula.ui.common.screen
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInWindow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarFadeThrough
|
||||
import de.jeanlucmakiola.calendula.ui.common.BelowTopBarClip
|
||||
import de.jeanlucmakiola.calendula.ui.common.StayingPut
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFab
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTopBar
|
||||
import de.jeanlucmakiola.calendula.ui.common.ChromeHost
|
||||
import de.jeanlucmakiola.calendula.ui.common.ChromeSlot
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalChromeSlot
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScroll
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewFocus
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewFocusSaver
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
|
||||
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScrollMemory
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewMorphScope
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform
|
||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.drillToWeek
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
||||
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimelineScreen
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Holds the top-level view back stack (spec M1) and swaps between the calendar
|
||||
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
||||
* pill in their top bars writes back here via [onSelectView].
|
||||
* screens. Each screen owns its own ViewModel; they all open on the one focused
|
||||
* date held here ([ViewFocus]), and the view-switcher pill in their top bars
|
||||
* writes back here via [onSelectView].
|
||||
*
|
||||
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
|
||||
* A lateral switch (pill / drawer) builds a visit history so back retraces it
|
||||
@@ -63,6 +113,7 @@ import kotlin.time.Clock
|
||||
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
|
||||
* widget's view.)
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun CalendarHost(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -72,6 +123,11 @@ 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(),
|
||||
) {
|
||||
// Wait for the persisted default view before seeding the stack, so the app
|
||||
@@ -79,17 +135,35 @@ fun CalendarHost(
|
||||
// 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))
|
||||
}
|
||||
var navigationVersion by remember { mutableIntStateOf(0) }
|
||||
val view = viewStack.last()
|
||||
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
|
||||
// The date every view opens on and carries along (#184); tapping a day
|
||||
// anywhere focuses it and opens the day view there.
|
||||
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) }
|
||||
|
||||
// Tapping a day in the month grid opens the day view anchored to that date.
|
||||
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val switchScope = rememberCoroutineScope()
|
||||
val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move ->
|
||||
navigationVersion++
|
||||
viewStack = move(viewStack)
|
||||
}
|
||||
val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } }
|
||||
val onOpenDay: (LocalDate) -> Unit = { date ->
|
||||
pendingDayIso = date.toString()
|
||||
viewStack = viewStack.drillToDay()
|
||||
focus.navigateTo(date)
|
||||
switchTo { it.drillToDay() }
|
||||
}
|
||||
val onOpenWeek: (LocalDate) -> Unit = { date ->
|
||||
focus.navigateTo(date)
|
||||
switchTo { it.drillToWeek() }
|
||||
}
|
||||
|
||||
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||
@@ -136,6 +210,10 @@ fun CalendarHost(
|
||||
// over Settings and survives view switches.
|
||||
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Backup & restore (#69) — hoisted like the manager, being driven by the
|
||||
// calendar list rather than by preferences. Reached from both.
|
||||
var showBackup by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Event form (v1.2 create) — same held-key pattern as the detail screen:
|
||||
// [heldCreateIso] keeps the prefill date alive through the slide-out.
|
||||
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
|
||||
@@ -165,12 +243,43 @@ 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) }
|
||||
// Where a restore came from, so closing the import puts it back: the import
|
||||
// overlays are declared under Backup & restore and the manager, so starting
|
||||
// one has to close them — without this, finishing a restore drops you on the
|
||||
// calendar and the next file means walking in through Settings again.
|
||||
var backupAfterImport by rememberSaveable { mutableStateOf(false) }
|
||||
var calendarsAfterImport by rememberSaveable { mutableStateOf(false) }
|
||||
// One import run. The import VM lives in the Activity's store, so this is
|
||||
// what tells it a *re-import of the same file* is new work and not the run
|
||||
// it already finished; a rotation keeps the number and keeps the result.
|
||||
var importSession by rememberSaveable { mutableStateOf(0) }
|
||||
LaunchedEffect(requestedImportUri) {
|
||||
if (requestedImportUri != null) {
|
||||
importUri = requestedImportUri
|
||||
importForceMany = false
|
||||
importSession++
|
||||
onImportConsumed()
|
||||
}
|
||||
}
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
// Close every overlay that can sit over the calendar, so an externally
|
||||
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
||||
@@ -178,24 +287,40 @@ fun CalendarHost(
|
||||
fun dismissCoveringOverlays() {
|
||||
showSettings = false
|
||||
showCalendars = false
|
||||
showBackup = false
|
||||
detailKey = null
|
||||
editKey = null
|
||||
importUri = null
|
||||
importForm = null
|
||||
}
|
||||
|
||||
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
|
||||
// widget) opens the occurrence straight in the edit form. Drop any covering
|
||||
// overlay first — the edit overlay sits below Settings/import in the Box, so
|
||||
// without this it would open hidden underneath them. Same held-key pattern as
|
||||
// a detail-screen "Edit" tap; a saved edit just returns to the calendar.
|
||||
LaunchedEffect(requestedEditKey) {
|
||||
if (requestedEditKey != null) {
|
||||
dismissCoveringOverlays()
|
||||
heldEditKey = requestedEditKey
|
||||
editKey = requestedEditKey
|
||||
onEditKeyConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
// A home-screen widget launch asks to open a date (→ day view), open an
|
||||
// event's detail, or start a create. Handled once and cleared, mirroring
|
||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||
// view so backing out returns there (then home), not to the default.
|
||||
LaunchedEffect(widgetNavRequest) {
|
||||
if (widgetNavRequest != null) navigationVersion++
|
||||
when (val req = widgetNavRequest) {
|
||||
is WidgetNavRequest.OpenDate -> {
|
||||
// Drill the day view in over the widget's view: drop any overlay
|
||||
// that would cover it, so the open doesn't land under Settings/form.
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
pendingDayIso = req.dateIso
|
||||
focus.navigateTo(LocalDate.parse(req.dateIso))
|
||||
// No widget source (an external date tap) roots over the default
|
||||
// home view, so backing out of the day returns home then exits.
|
||||
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
||||
@@ -212,6 +337,17 @@ fun CalendarHost(
|
||||
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
|
||||
focus.navigateTo(localToday())
|
||||
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
|
||||
@@ -230,75 +366,215 @@ fun CalendarHost(
|
||||
}
|
||||
}
|
||||
|
||||
// Drag to reschedule (#68). Hosted here so one instance serves every view and
|
||||
// survives view switches; the calendar surfaces read it out of LocalEventMove.
|
||||
val reschedule: RescheduleViewModel = hiltViewModel()
|
||||
val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle()
|
||||
val onEditEvent: (EventInstance) -> Unit = { event ->
|
||||
val key = longArrayOf(
|
||||
event.eventId,
|
||||
event.start.toEpochMilliseconds(),
|
||||
event.end.toEpochMilliseconds(),
|
||||
)
|
||||
heldEditKey = key
|
||||
editKey = key
|
||||
}
|
||||
// Off by preference (#173) switches off the gesture, not the scope: blocks
|
||||
// register no drag, while the edit form and its TalkBack action stay put.
|
||||
val dragToReschedule = viewModel.dragToReschedule.collectAsStateWithLifecycle().value
|
||||
val moveScope = remember(movableCalendarIds, reschedule, dragToReschedule) {
|
||||
EventMoveScope(
|
||||
movableCalendarIds = movableCalendarIds,
|
||||
dragEnabled = dragToReschedule,
|
||||
move = { reschedule.move(it) },
|
||||
inFlight = reschedule.inFlight,
|
||||
undoStarted = reschedule.undoStarted,
|
||||
abandoned = reschedule.abandoned,
|
||||
edit = onEditEvent,
|
||||
)
|
||||
}
|
||||
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
// Overlays leave from the predictive-back preview (#184): shrinking on from
|
||||
// where the gesture let go instead of snapping back and sliding off.
|
||||
val overlayExit = predictiveBackExit()
|
||||
|
||||
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
||||
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
||||
// so back there falls through to the system and exits the app.
|
||||
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
||||
editKey != null || showSettings || showCalendars || importUri != null ||
|
||||
importForm != null
|
||||
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
||||
viewStack = viewStack.dropLast(1)
|
||||
editKey != null || showSettings || showCalendars || showBackup ||
|
||||
importUri != null || importForm != null
|
||||
// The view switch runs on a seekable transition, so a back gesture can scrub
|
||||
// it (#185): the previous view morphs in under the finger, releasing finishes
|
||||
// the pop and cancelling springs back. Reduced motion keeps a plain back.
|
||||
val viewTransition = remember { SeekableTransitionState(view) }
|
||||
LaunchedEffect(view) { viewTransition.animateTo(view) }
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val viewBackEnabled = !anyOverlayVisible && viewStack.size > 1
|
||||
if (reduceMotion) {
|
||||
BackHandler(enabled = viewBackEnabled) {
|
||||
navigationVersion++
|
||||
viewStack = viewStack.dropLast(1)
|
||||
}
|
||||
} else {
|
||||
PredictiveBackHandler(enabled = viewBackEnabled) { events ->
|
||||
val previous = viewStack[viewStack.size - 2]
|
||||
try {
|
||||
events.collect { viewTransition.seekTo(it.progress, targetState = previous) }
|
||||
navigationVersion++
|
||||
viewStack = viewStack.dropLast(1)
|
||||
} catch (e: CancellationException) {
|
||||
withContext(NonCancellable) { viewTransition.animateTo(viewTransition.currentState) }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
// Switching between the peer views (month/week/day/agenda) is lateral
|
||||
// Switching between the month, timeline and agenda screens is lateral
|
||||
// navigation, so it fades through rather than sliding — paging *within* a
|
||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||
val viewSwitch = calendarFadeThrough()
|
||||
AnimatedContent(
|
||||
targetState = view,
|
||||
transitionSpec = { viewSwitch },
|
||||
label = "view-switch",
|
||||
) { currentView ->
|
||||
when (currentView) {
|
||||
CalendarView.Week -> WeekScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
initialDateIso = pendingDayIso,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
// view keeps the directional slide. What both views show morphs across
|
||||
// (#184): see [ViewMorphKey]. Reduced motion keeps the plain fade.
|
||||
val viewSwitch = fadeThrough()
|
||||
val morphBounds = rememberViewMorphBoundsTransform()
|
||||
val morphFade = rememberCalendarFadeSpec()
|
||||
val morphClip = remember { BelowTopBarClip() }
|
||||
val stayingPut = remember { StayingPut() }
|
||||
val chromeHost = remember { ChromeHost() }
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val density = LocalDensity.current
|
||||
val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) +
|
||||
with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
||||
val timelineScroll = remember { TimelineScrollMemory() }
|
||||
// A timeline reached from another screen opens on the visible hours
|
||||
// (#322); only a switch between timeline views carries the scroll over.
|
||||
val timelineZoom = LocalTimelineZoom.current
|
||||
LaunchedEffect(view.screen) {
|
||||
if (view.screen != ViewScreen.Timeline && timelineZoom.hours.enabled) {
|
||||
timelineScroll.midnightY = null
|
||||
}
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalEventMove provides moveScope,
|
||||
LocalTimelineScroll provides timelineScroll,
|
||||
LocalViewFocus provides focus,
|
||||
// The failure state's way out when every calendar is switched off
|
||||
// (#239); nothing else in the calendar surfaces navigates here.
|
||||
LocalManageCalendars provides remember { { showCalendars = true } },
|
||||
) {
|
||||
// One drawer, top bar and FAB for every screen (#184): a switch changes
|
||||
// what they say, from what the screen on show publishes, rather than
|
||||
// swapping them for another screen's.
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
// Open only via the menu button where edge-swipe would fight paging;
|
||||
// the agenda has no paging to fight.
|
||||
gesturesEnabled = drawerState.isOpen || view.screen == ViewScreen.Agenda,
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
currentView = view,
|
||||
currentDate = chromeHost.chrome?.drawerDate ?: focus.date,
|
||||
drawerState = drawerState,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { target ->
|
||||
// Once the drawer is out of the way, so the switch is seen whole.
|
||||
val requestVersion = ++navigationVersion
|
||||
switchScope.launch {
|
||||
drawerState.close()
|
||||
if (navigationVersion == requestVersion) onSelectView(target)
|
||||
}
|
||||
},
|
||||
onJumpToDate = { target ->
|
||||
chromeHost.actions?.onJumpToDate?.invoke(target)
|
||||
switchScope.launch { drawerState.close() }
|
||||
},
|
||||
onSettings = {
|
||||
onOpenSettings()
|
||||
switchScope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CalendarTopBar(
|
||||
host = chromeHost,
|
||||
selectedView = view,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
onCycleView = { onSelectView(viewStack.last().next(quickSwitchViews)) },
|
||||
onOpenDrawer = { switchScope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = todayInToolbar,
|
||||
)
|
||||
},
|
||||
floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) },
|
||||
) { innerPadding ->
|
||||
SharedTransitionLayout(
|
||||
Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y },
|
||||
) {
|
||||
// Keyed on the screen, not the view: the day, multi-day and week views
|
||||
// are one timeline that resizes between them itself (#184), so switching
|
||||
// among them must not replay this transition.
|
||||
rememberTransition(viewTransition, label = "view-switch").AnimatedContent(
|
||||
transitionSpec = { viewSwitch },
|
||||
contentKey = { it.screen },
|
||||
) { currentView ->
|
||||
val screen = currentView.screen
|
||||
val sharedScope = this@SharedTransitionLayout
|
||||
val visibilityScope = this@AnimatedContent
|
||||
val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut) {
|
||||
if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalViewMorph provides viewMorph,
|
||||
LocalChromeSlot provides ChromeSlot(chromeHost, current = screen == view.screen),
|
||||
) {
|
||||
when (screen) {
|
||||
ViewScreen.Timeline -> TimelineScreen(
|
||||
view = currentView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
ViewScreen.Month -> MonthScreen(
|
||||
onOpenDay = onOpenDay,
|
||||
onOpenWeek = onOpenWeek,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
ViewScreen.Agenda -> AgendaScreen(
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scope prompt + confirmation/undo snackbar for a dropped event, declared
|
||||
// right after the calendar views so later overlays cover it.
|
||||
EventMoveHost(reschedule, modifier = Modifier.fillMaxSize())
|
||||
|
||||
// Search overlay — below detail/edit in the Box so a tapped result's
|
||||
// detail screen draws on top, and closing it returns to the results.
|
||||
AnimatedVisibility(
|
||||
visible = showSearch,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
SearchScreen(
|
||||
onBack = { showSearch = false },
|
||||
onEventClick = onEventClick,
|
||||
visible = showSearch,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -307,7 +583,7 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = detailKey != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
activeKey?.let { key ->
|
||||
EventDetailScreen(
|
||||
@@ -319,6 +595,15 @@ 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
|
||||
},
|
||||
visible = detailKey != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -327,7 +612,7 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = createDateIso != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
(createDateIso ?: heldCreateIso)?.let { iso ->
|
||||
EventEditScreen(
|
||||
@@ -335,6 +620,8 @@ fun CalendarHost(
|
||||
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||
onClose = { createDateIso = null },
|
||||
onSaved = { createDateIso = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
visible = createDateIso != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -343,7 +630,7 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = editKey != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
(editKey ?: heldEditKey)?.let { key ->
|
||||
EventEditScreen(
|
||||
@@ -354,6 +641,8 @@ fun CalendarHost(
|
||||
editKey = null
|
||||
detailKey = null
|
||||
},
|
||||
onManageCalendars = { showCalendars = true },
|
||||
visible = editKey != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -362,31 +651,44 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = showSettings,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
SettingsScreen(
|
||||
onBack = { showSettings = false },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
onOpenBackup = { showBackup = true },
|
||||
visible = showSettings,
|
||||
)
|
||||
}
|
||||
|
||||
// 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,
|
||||
onClose = { importUri = null },
|
||||
session = importSession,
|
||||
forceMany = importForceMany,
|
||||
onClose = {
|
||||
importUri = null
|
||||
// Back to the surface the restore started from, ready for
|
||||
// the next file.
|
||||
showCalendars = calendarsAfterImport
|
||||
showBackup = backupAfterImport
|
||||
calendarsAfterImport = false
|
||||
backupAfterImport = false
|
||||
},
|
||||
onManageCalendars = {
|
||||
// The manager is where this leads, so it must not be
|
||||
// reopened underneath on close.
|
||||
calendarsAfterImport = false
|
||||
backupAfterImport = false
|
||||
showCalendars = true
|
||||
},
|
||||
onOpenSingle = { form ->
|
||||
importUri = null
|
||||
calendarsAfterImport = false
|
||||
backupAfterImport = false
|
||||
importFormSource = ImportSource.File
|
||||
importForm = form
|
||||
},
|
||||
)
|
||||
@@ -395,10 +697,62 @@ 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 = overlayExit,
|
||||
) {
|
||||
CalendarsScreen(
|
||||
onBack = { showCalendars = false },
|
||||
onOpenBackup = { showBackup = true },
|
||||
visible = showCalendars,
|
||||
)
|
||||
}
|
||||
|
||||
// Backup & restore — over the manager, since the manager links into it.
|
||||
AnimatedVisibility(
|
||||
visible = showBackup,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
// Settings can open this screen without the manager underneath, so
|
||||
// its failure states' "Manage calendars" way out has to open the
|
||||
// manager rather than just pop — popping alone lands on Settings
|
||||
// (#304). Coming from the manager the flag is already set, so this
|
||||
// is the same pop-back there.
|
||||
CompositionLocalProvider(
|
||||
LocalManageCalendars provides {
|
||||
showCalendars = true
|
||||
showBackup = false
|
||||
},
|
||||
) {
|
||||
BackupScreen(
|
||||
onBack = { showBackup = false },
|
||||
// Restore runs the normal .ics import, and both this screen
|
||||
// and the manager that can have opened it are declared above
|
||||
// the import overlays — so both have to step aside.
|
||||
onImport = {
|
||||
importUri = it
|
||||
importForceMany = true
|
||||
importSession++
|
||||
backupAfterImport = true
|
||||
calendarsAfterImport = showCalendars
|
||||
showBackup = false
|
||||
showCalendars = false
|
||||
},
|
||||
visible = showBackup,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,3 +761,6 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
||||
save = { stack -> stack.map(CalendarView::ordinal) },
|
||||
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
|
||||
)
|
||||
|
||||
private fun localToday(): LocalDate =
|
||||
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
|
||||
@@ -5,8 +5,10 @@ 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
|
||||
|
||||
@@ -26,4 +28,43 @@ class CalendarHostViewModel @Inject constructor(
|
||||
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,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether events may be dragged to another slot to reschedule them (#68, #173).
|
||||
* Off until the stored value arrives, even though the preference itself defaults
|
||||
* on: the wrong guess costs an enabled user a gesture for the few frames a cold
|
||||
* start takes to read DataStore, but re-arms the one thing a disabled user
|
||||
* switched off to be rid of.
|
||||
*/
|
||||
val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@ package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -21,11 +25,22 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.onboardingSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.rememberOnboardingSlideSpec
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
|
||||
/** What the root is showing: a wizard step, the app, or neither yet. */
|
||||
private sealed interface RootTarget {
|
||||
/** DataStore has not emitted yet — render nothing rather than the wrong screen. */
|
||||
data object Loading : RootTarget
|
||||
data object App : RootTarget
|
||||
data class Step(val step: OnboardingStep) : RootTarget
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun RootScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -35,6 +50,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 {
|
||||
@@ -44,6 +65,10 @@ fun RootScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// A launch scan already covers the app coming up with the permission, so
|
||||
// only a grant made during this session owes a re-scan.
|
||||
val grantedAtLaunch = remember { hasPermission }
|
||||
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle) {
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
@@ -63,40 +88,87 @@ fun RootScreen(
|
||||
onDispose { lifecycle.removeObserver(obs) }
|
||||
}
|
||||
|
||||
// Cross-fade the one-time onboarding gates so granting permission / finishing
|
||||
// onboarding eases into the next screen instead of snapping. A fade carries no
|
||||
// spatial motion, so it stays appropriate under "remove animations" too.
|
||||
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||
Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted ->
|
||||
if (granted) {
|
||||
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
||||
// Null until DataStore's first emission — render nothing for that
|
||||
// frame instead of flashing the wrong screen.
|
||||
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
||||
when (done) {
|
||||
true -> CalendarHost(
|
||||
modifier = modifier,
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||
widgetNavRequest = widgetNavRequest,
|
||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = onImportConsumed,
|
||||
)
|
||||
false -> ReminderOnboardingScreen(
|
||||
onFinished = reminderOnboarding::finish,
|
||||
modifier = modifier,
|
||||
)
|
||||
null -> {}
|
||||
}
|
||||
val onboarding: OnboardingViewModel = hiltViewModel()
|
||||
val plan by onboarding.plan.collectAsStateWithLifecycle()
|
||||
// The grant is reported however it was taken, including via Android's
|
||||
// app-settings screen (caught by the ON_RESUME above); arming the wizard
|
||||
// hangs off that, not off the in-app callback.
|
||||
LaunchedEffect(hasPermission) {
|
||||
onboarding.setHasPermission(hasPermission)
|
||||
if (hasPermission && !grantedAtLaunch) onboarding.onPermissionGranted()
|
||||
}
|
||||
|
||||
// Arms the one-time explainer for the switch to the device's own calendar
|
||||
// visibility (#75), which the wizard then carries as a step of its own.
|
||||
LaunchedEffect(hasPermission) {
|
||||
if (hasPermission) onboarding.reconcileCalendarVisibility()
|
||||
}
|
||||
|
||||
val current = plan
|
||||
val target: RootTarget = when {
|
||||
current == null -> RootTarget.Loading
|
||||
current.current == null -> RootTarget.App
|
||||
else -> RootTarget.Step(current.current)
|
||||
}
|
||||
|
||||
// Which way the flow is moving, so a step-back slides back. The app sorts
|
||||
// after every step, so finishing the wizard reads as one more move forward.
|
||||
val ordinal = when (target) {
|
||||
RootTarget.Loading -> Int.MIN_VALUE
|
||||
RootTarget.App -> Int.MAX_VALUE
|
||||
is RootTarget.Step -> current?.steps?.indexOf(target.step) ?: 0
|
||||
}
|
||||
var lastOrdinal by remember { mutableIntStateOf(ordinal) }
|
||||
val slideDir = if (ordinal < lastOrdinal) -1 else 1
|
||||
SideEffect { lastOrdinal = ordinal }
|
||||
|
||||
val slideSpec = rememberOnboardingSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
AnimatedContent(
|
||||
targetState = target,
|
||||
transitionSpec = {
|
||||
// Coming off the blank first frame is not a step change.
|
||||
if (initialState == RootTarget.Loading) {
|
||||
fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||
} else {
|
||||
onboardingSlideTransition(
|
||||
slideDir = slideDir,
|
||||
spec = slideSpec,
|
||||
fadeSpec = fadeSpec,
|
||||
reduceMotion = reduceMotion,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
PermissionScreen(
|
||||
onGranted = { hasPermission = true },
|
||||
},
|
||||
label = "onboardingGate",
|
||||
) { shown ->
|
||||
when (shown) {
|
||||
RootTarget.Loading -> Unit
|
||||
RootTarget.App -> CalendarHost(
|
||||
modifier = modifier,
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||
widgetNavRequest = widgetNavRequest,
|
||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = onImportConsumed,
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
requestedInsertSource = requestedInsertSource,
|
||||
onInsertConsumed = onInsertConsumed,
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = onEditKeyConsumed,
|
||||
)
|
||||
// Both halves of a transition read their position out of the live
|
||||
// plan; the one the outgoing step was drawn with is already gone.
|
||||
is RootTarget.Step -> current?.let { plan ->
|
||||
OnboardingSteps(
|
||||
step = shown.step,
|
||||
plan = plan,
|
||||
viewModel = onboarding,
|
||||
onPermissionGranted = { hasPermission = true },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,4 +31,13 @@ sealed interface 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
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -87,24 +86,37 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
|
||||
|
||||
/**
|
||||
* The concrete span the range covers, starting at [start] through [end]
|
||||
* (inclusive), for a human-readable header:
|
||||
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
|
||||
* (inclusive), for a human-readable header. Both ends use the same day-month
|
||||
* order so a span never mixes "15 Jul" with "Aug 13, 2026":
|
||||
* - [AgendaRange.Day] → a single date ("27 Jun 2026")
|
||||
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026")
|
||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026"), with the start's
|
||||
* year shown too only when it differs from the end's.
|
||||
*
|
||||
* [monthAsSpan] puts [AgendaRange.ThisMonth] on the "start – end" form as well.
|
||||
* The agenda's own header browses the whole month, so the month name is right
|
||||
* there; the range picker previews the window an option opens *today*, which for
|
||||
* "This month" is only the rest of it.
|
||||
*/
|
||||
fun agendaRangeWindowSummary(
|
||||
range: AgendaRange,
|
||||
start: LocalDate,
|
||||
end: LocalDate,
|
||||
locale: Locale,
|
||||
monthAsSpan: Boolean = false,
|
||||
): String {
|
||||
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
||||
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
||||
val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
return when (range) {
|
||||
AgendaRange.Day -> medium.format(javaStart)
|
||||
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
|
||||
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} – ${medium.format(javaEnd)}"
|
||||
val dayMonth = localizedDateFormatter(locale, "dMMM")
|
||||
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
|
||||
return when {
|
||||
range == AgendaRange.Day -> dayMonthYear.format(javaStart)
|
||||
range == AgendaRange.ThisMonth && !monthAsSpan ->
|
||||
localizedDateFormatter(locale, "LLLLy").format(javaStart)
|
||||
else -> {
|
||||
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
|
||||
"${startFmt.format(javaStart)} – ${dayMonthYear.format(javaEnd)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
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.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
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 = declinedTitle(title, event.isDeclined),
|
||||
summary = AnnotatedString(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,9 +1,19 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenChrome
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
|
||||
import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
||||
import de.jeanlucmakiola.calendula.ui.common.morphEvent
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -15,39 +25,26 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Coffee
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
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
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -58,53 +55,44 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
// No file-level zone constant here on purpose: it would be fixed for the process
|
||||
// lifetime and drift from the zone AgendaViewModel groups in after a device
|
||||
// time-zone change. The zone travels on AgendaUiState.Success instead.
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AgendaScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AgendaViewModel = hiltViewModel(),
|
||||
) {
|
||||
// Opens on the focused date and hands back the day scrolled to the top.
|
||||
val focus = LocalViewFocus.current
|
||||
EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) }
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
var showRangePicker by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -114,84 +102,102 @@ fun AgendaScreen(
|
||||
}
|
||||
val successState = state as? AgendaUiState.Success
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = anchor,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onJumpToDate = { target ->
|
||||
viewModel.goToDate(target)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onSettings = {
|
||||
onOpenSettings()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
AgendaTopBar(
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
// The agenda window always starts at the anchor, so moving through it is a
|
||||
// list scroll, not an anchor change — "today" has to bring the list back to
|
||||
// the top as well, and stay offered while it is scrolled away (#305).
|
||||
val listState = rememberLazyListState()
|
||||
val scrolledAway by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
|
||||
}
|
||||
}
|
||||
// A new window is rendered from its first day, so the list belongs at the
|
||||
// top. Keyed on the anchor the days arrived with, not the one just
|
||||
// requested: scrolling before the new rows compose leaves the list keyed to
|
||||
// a row that reappears further down, and it follows it there.
|
||||
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
|
||||
val jumpToToday: () -> Unit = {
|
||||
// Off today, moving the anchor is what resets the list (above); on today
|
||||
// the window doesn't change, so the scroll back is the whole action.
|
||||
if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
|
||||
viewModel.goToToday()
|
||||
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
|
||||
}
|
||||
|
||||
PublishChrome(
|
||||
ScreenChrome(
|
||||
title = stringResource(R.string.view_agenda),
|
||||
titleDate = null,
|
||||
drawerDate = anchor,
|
||||
awayFromToday = !isOnToday || (scrolledAway && successState != null),
|
||||
todayText = stringResource(R.string.agenda_today_action),
|
||||
),
|
||||
ScreenActions(
|
||||
onToday = jumpToToday,
|
||||
onJumpToDate = { target ->
|
||||
focus?.date = target
|
||||
viewModel.goToDate(target)
|
||||
},
|
||||
floatingActionButton = {
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnToday,
|
||||
todayText = stringResource(R.string.agenda_today_action),
|
||||
onToday = viewModel::goToToday,
|
||||
onCreate = { onCreateEvent(anchor, null) },
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
// One bar at the top: the "showing …" header on the left and the
|
||||
// session range switcher on the right (one settings toggle).
|
||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
AgendaRangeBanner(
|
||||
range = s.range,
|
||||
start = s.anchor,
|
||||
end = s.rangeEnd,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
AgendaRangePill(
|
||||
range = s.range,
|
||||
isOverride = s.rangeIsOverride,
|
||||
onClick = { showRangePicker = true },
|
||||
)
|
||||
}
|
||||
onCreate = { onCreateEvent(anchor, null) },
|
||||
),
|
||||
)
|
||||
|
||||
ScreenBody(modifier = modifier) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
// One bar at the top: the "showing …" header on the left and the
|
||||
// session range switcher on the right (one settings toggle).
|
||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||
// end lines the selector up with whatever ends the top bar:
|
||||
// the view switcher's background, or — once #150 hides it —
|
||||
// the search icon's glyph.
|
||||
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
|
||||
AppBarSpacing.Inset
|
||||
} else {
|
||||
AppBarSpacing.IconTrailingInset
|
||||
}
|
||||
AgendaContent(
|
||||
state = state,
|
||||
pastDisplay = pastDisplay,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = RANGE_BAR_TEXT_INSET,
|
||||
end = selectorEnd,
|
||||
top = 8.dp,
|
||||
bottom = 8.dp,
|
||||
),
|
||||
) {
|
||||
AgendaRangeBanner(
|
||||
range = s.range,
|
||||
start = s.anchor,
|
||||
end = s.rangeEnd,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
AgendaRangePill(
|
||||
range = s.range,
|
||||
isOverride = s.rangeIsOverride,
|
||||
onClick = { showRangePicker = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
AgendaContent(
|
||||
state = state,
|
||||
pastDisplay = pastDisplay,
|
||||
showToday = showToday,
|
||||
listState = listState,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onTopDay = if (successState?.anchor == anchor) {
|
||||
{ focus?.date = it }
|
||||
} else null,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +206,7 @@ fun AgendaScreen(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
description = stringResource(R.string.agenda_range_override_hint),
|
||||
selected = successState?.range ?: AgendaRange.Month,
|
||||
weekStart = weekStart,
|
||||
onSelect = viewModel::setRangeOverride,
|
||||
onDismiss = { showRangePicker = false },
|
||||
)
|
||||
@@ -207,9 +214,11 @@ fun AgendaScreen(
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact tonal pill showing the agenda's current range. Tapping it opens the
|
||||
* range picker as a session-only override. Filled with the primary container
|
||||
* while an override is active, so the temporary state is obvious.
|
||||
* The agenda's current range, tapped to open the range picker as a session-only
|
||||
* override. Shares the top-bar view switcher's button shape so the two read as a
|
||||
* family, but stays low-emphasis — a subtle neutral surface tint rather than the
|
||||
* switcher's secondary container, so it doesn't compete. Fills with the primary
|
||||
* container only while an override is active, to make that temporary state clear.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangePill(
|
||||
@@ -218,43 +227,37 @@ private fun AgendaRangePill(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val container = if (isOverride) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
val content = if (isOverride) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Surface(
|
||||
color = container,
|
||||
contentColor = content,
|
||||
shape = RoundedCornerShape(50),
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
FilledTonalButton(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = if (isOverride) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
},
|
||||
contentColor = if (isOverride) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DateRange,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = agendaRangeLabel(range),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = agendaRangeLabel(range),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Optical start inset for the range bar's text, set against the title above it. */
|
||||
private val RANGE_BAR_TEXT_INSET = 28.dp
|
||||
|
||||
/**
|
||||
* A header naming the concrete window currently shown, e.g. "Showing all events
|
||||
* for · Today, 27 Jun 2026" / "This week, 27 Jun – 3 Jul" / "This month, June 2026".
|
||||
* A header naming the concrete window currently shown under a "showing …" label,
|
||||
* e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name
|
||||
* lives on the selector button beside it, so it isn't repeated here.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangeBanner(
|
||||
@@ -271,8 +274,10 @@ private fun AgendaRangeBanner(
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Just the concrete dates — the range's name ("Next 30 days") already
|
||||
// sits on the selector button to the right, so repeating it here is noise.
|
||||
Text(
|
||||
text = "${agendaRangeLabel(range)}, $window",
|
||||
text = window,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
@@ -280,12 +285,16 @@ private fun AgendaRangeBanner(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaContent(
|
||||
internal fun AgendaContent(
|
||||
state: AgendaUiState,
|
||||
pastDisplay: PastEventDisplay,
|
||||
showToday: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
onTopDay: ((LocalDate) -> Unit)? = null,
|
||||
) {
|
||||
when (state) {
|
||||
AgendaUiState.Loading -> Box(modifier)
|
||||
@@ -297,7 +306,7 @@ private fun AgendaContent(
|
||||
// Hiding drops finished events — and any day they leave empty; dimming
|
||||
// keeps them but fades the row. Recomputed each minute so events fall
|
||||
// away (or fade) as they end while the screen stays open.
|
||||
val days = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
state.days.mapNotNull { day ->
|
||||
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||
@@ -305,15 +314,30 @@ private fun AgendaContent(
|
||||
} else {
|
||||
state.days
|
||||
}
|
||||
// Anchor today with a "nothing left today" placeholder — but only when
|
||||
// the window actually starts on today; a jumped-to date has no today in
|
||||
// it, so anchoring there would be misleading (#35).
|
||||
val days = anchorTodayIfMissing(
|
||||
days = filtered,
|
||||
today = state.today,
|
||||
enabled = showToday && state.anchor == state.today,
|
||||
)
|
||||
if (days.isEmpty()) {
|
||||
// Nothing to scroll: drop the position a previous list left, so
|
||||
// the today FAB doesn't linger over an empty screen.
|
||||
LaunchedEffect(Unit) { listState.scrollToItem(0) }
|
||||
AgendaEmpty(modifier)
|
||||
} else {
|
||||
AgendaList(
|
||||
days = days,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
listState = listState,
|
||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||
now = now,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onTopDay = onTopDay,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -326,83 +350,74 @@ private fun AgendaContent(
|
||||
private fun AgendaList(
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
listState: LazyListState,
|
||||
dimPast: Boolean,
|
||||
now: Instant,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onTopDay: ((LocalDate) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (onTopDay != null) {
|
||||
val itemDates by rememberUpdatedState(agendaItemDates(days))
|
||||
val currentOnTopDay by rememberUpdatedState(onTopDay)
|
||||
// The day the list opened on is where the view already is; only a
|
||||
// scroll moves it on.
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.firstVisibleItemIndex }
|
||||
.drop(1)
|
||||
.collect { index -> itemDates.getOrNull(index)?.let(currentOnTopDay) }
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
state = listState,
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
days.forEach { day ->
|
||||
stickyHeader(key = "header-${day.date}") {
|
||||
AgendaDayHeader(date = day.date, today = today)
|
||||
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
||||
}
|
||||
itemsIndexed(
|
||||
items = day.events,
|
||||
key = { _, event -> event.instanceId },
|
||||
) { index, event ->
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = calendarAnimateItem(),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
if (day.events.isEmpty()) {
|
||||
// An anchored, event-less today (#35) — "nothing left today".
|
||||
item(key = "placeholder-${day.date}") {
|
||||
AgendaEmptyDayRow(
|
||||
text = stringResource(R.string.agenda_no_more_today),
|
||||
onClick = { onOpenDay(day.date) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(
|
||||
items = day.events,
|
||||
// Scope the key by day: a multi-day event appears under every
|
||||
// day it spans, so its instanceId alone is not unique across
|
||||
// the list (LazyColumn requires unique keys).
|
||||
key = { _, event -> "${day.date}-${event.instanceId}" },
|
||||
) { index, event ->
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
day = day.date,
|
||||
zone = zone,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
|
||||
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),
|
||||
)
|
||||
/** The day of each item [AgendaList] lays out for [days], in its order. */
|
||||
internal fun agendaItemDates(days: List<AgendaDay>): List<LocalDate> =
|
||||
days.flatMap { day ->
|
||||
// Header, the placeholder or the events, then the gap.
|
||||
List(2 + day.events.size.coerceAtLeast(1)) { day.date }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaEventRow(
|
||||
event: EventInstance,
|
||||
position: Position,
|
||||
dimmed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = title,
|
||||
summary = agendaTimeSummary(event),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
leading = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 6.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(pastelize(event.color, dark)),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
@@ -425,87 +440,3 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AgendaTopBar(
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.view_agenda),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(R.string.month_open_menu),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = stringResource(R.string.search_action),
|
||||
)
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
onCycle = onCycleView,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
||||
@Composable
|
||||
private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
||||
val relative = when (date) {
|
||||
today -> stringResource(R.string.agenda_header_today)
|
||||
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
||||
else -> null
|
||||
}
|
||||
val formatted = formatAgendaDate(date)
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
/** Time line under the title: "09:00 – 10:00 · Location", "All day", etc. */
|
||||
@Composable
|
||||
private fun agendaTimeSummary(event: EventInstance): String {
|
||||
val time = if (event.isAllDay) {
|
||||
stringResource(R.string.event_detail_all_day)
|
||||
} else {
|
||||
val is24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
"${formatTime(event.start, is24Hour, locale)} – ${formatTime(event.end, is24Hour, locale)}"
|
||||
}
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): String {
|
||||
val t = instant.toLocalDateTime(zone).time
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
||||
}
|
||||
|
||||
private fun formatAgendaDate(date: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
return "$weekday, ${date.day}. $monthName ${date.year}"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* What an agenda row's time line should convey for an event on a given day —
|
||||
* the part of a multi-day span that [day] falls in. Pure and shared so the
|
||||
* agenda screen and the agenda widget label multi-day events identically; each
|
||||
* surface only formats the instants into its own locale/24h string.
|
||||
*/
|
||||
sealed interface AgendaTimeLabel {
|
||||
/** An all-day event, or a whole in-between day of a multi-day span. */
|
||||
data object AllDay : AgendaTimeLabel
|
||||
/** The first day of a multi-day timed event: when it begins. */
|
||||
data class Starts(val start: Instant) : AgendaTimeLabel
|
||||
/** The last day of a multi-day timed event: when it ends. */
|
||||
data class Ends(val end: Instant) : AgendaTimeLabel
|
||||
/** A single-day timed event: its start–end range. */
|
||||
data class Range(val start: Instant, val end: Instant) : AgendaTimeLabel
|
||||
}
|
||||
|
||||
/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */
|
||||
fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel {
|
||||
if (event.isAllDay) return AgendaTimeLabel.AllDay
|
||||
val firstDay = event.spanFirstDay(zone)
|
||||
val lastDay = event.spanLastDay(zone)
|
||||
return when {
|
||||
firstDay == lastDay -> AgendaTimeLabel.Range(event.start, event.end)
|
||||
day <= firstDay -> AgendaTimeLabel.Starts(event.start)
|
||||
day >= lastDay -> AgendaTimeLabel.Ends(event.end)
|
||||
else -> AgendaTimeLabel.AllDay // a full in-between day
|
||||
}
|
||||
}
|
||||
|
||||
/** One calendar day with at least one event, for the agenda list. */
|
||||
data class AgendaDay(
|
||||
@@ -15,31 +50,65 @@ data class AgendaDay(
|
||||
|
||||
/**
|
||||
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
|
||||
* actually carry events). An event that began before [anchor] (ongoing or
|
||||
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a
|
||||
* day, all-day events sort first, then ascending by start time, then title.
|
||||
* actually carry events). A multi-day event surfaces on *every* day it spans,
|
||||
* not just its first — clamped to [[anchor], [windowEnd]] so an event that began
|
||||
* before the window (ongoing) still lists from the anchor day, and one running
|
||||
* past the window stops at the last visible day. Within a day, all-day events
|
||||
* sort first, then ascending by start time, then title.
|
||||
*
|
||||
* Shared by the Agenda screen and the agenda home-screen widget so both group
|
||||
* and order identically.
|
||||
*/
|
||||
fun groupAgendaDays(
|
||||
anchor: LocalDate,
|
||||
windowEnd: LocalDate,
|
||||
instances: List<EventInstance>,
|
||||
zone: TimeZone,
|
||||
): List<AgendaDay> =
|
||||
instances
|
||||
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) }
|
||||
.toSortedMap()
|
||||
.map { (date, dayEvents) ->
|
||||
AgendaDay(
|
||||
date = date,
|
||||
events = dayEvents.sortedWith(
|
||||
compareByDescending<EventInstance> { it.isAllDay }
|
||||
.thenBy { it.start }
|
||||
.thenBy { it.title },
|
||||
),
|
||||
)
|
||||
): List<AgendaDay> {
|
||||
val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>()
|
||||
for (instance in instances) {
|
||||
val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor)
|
||||
val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd)
|
||||
// Skip instances that don't actually occupy any day in [[anchor], [windowEnd]].
|
||||
// The provider returns an event whenever its instant span overlaps the query
|
||||
// window, but all-day events live at UTC midnights with an exclusive end: east
|
||||
// of UTC that end dips just past local midnight, so *yesterday's* all-day event
|
||||
// overlaps today's window start and comes back even though its true last day
|
||||
// (resolved in UTC) is before the anchor. Clamping it up to the anchor would
|
||||
// surface it under "today" (issue #65); drop it instead. The symmetric case —
|
||||
// a next-day all-day event overlapping the window's last instant — drops too.
|
||||
if (lastDay < firstDay) continue
|
||||
var day = firstDay
|
||||
while (day <= lastDay) {
|
||||
byDay.getOrPut(day) { mutableListOf() }.add(instance)
|
||||
day = day.plus(1, DateTimeUnit.DAY)
|
||||
}
|
||||
}
|
||||
return byDay.map { (date, dayEvents) ->
|
||||
AgendaDay(
|
||||
date = date,
|
||||
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure [today] surfaces as the first agenda day even when it carries no
|
||||
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget
|
||||
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and
|
||||
* when today already has its own day in [days]. Keeps today anchored at the top
|
||||
* so a glance tells today's events apart from a future day's (issue #35).
|
||||
*/
|
||||
fun anchorTodayIfMissing(
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
enabled: Boolean,
|
||||
): List<AgendaDay> =
|
||||
if (enabled && days.none { it.date == today }) {
|
||||
listOf(AgendaDay(today, emptyList())) + days
|
||||
} else {
|
||||
days
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
||||
@@ -61,5 +130,12 @@ sealed interface AgendaUiState {
|
||||
val rangeEnd: LocalDate,
|
||||
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
|
||||
val showRangeBar: Boolean,
|
||||
/**
|
||||
* The zone [days] were grouped in. Carried in the state rather than
|
||||
* re-read by the screen so labelling and grouping cannot disagree: an
|
||||
* event's "Starts …/Ends …/All day" line is only correct relative to the
|
||||
* same zone that decided which day it was filed under.
|
||||
*/
|
||||
val zone: TimeZone,
|
||||
) : AgendaUiState
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -20,13 +21,11 @@ import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import java.util.Locale
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
@@ -50,9 +49,12 @@ class AgendaViewModel @Inject constructor(
|
||||
settingsPrefs.agendaShowRangeBar,
|
||||
) { range, showBar -> AgendaSettings(range, showBar) }
|
||||
|
||||
// First day of the week, for the calendar-aligned "this week" range.
|
||||
private val weekStartDay = settingsPrefs.weekStart
|
||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
||||
/**
|
||||
* First day of the week, for the calendar-aligned "this week" range. Public
|
||||
* because the range picker resolves each option to real dates, which needs
|
||||
* the same week start the window is built from.
|
||||
*/
|
||||
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||
|
||||
/**
|
||||
* How to treat events that already ended today (show / dim / hide). A display
|
||||
@@ -66,6 +68,19 @@ class AgendaViewModel @Inject constructor(
|
||||
initialValue = PastEventDisplay.SHOW,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether to keep today anchored at the top with a "nothing left today"
|
||||
* placeholder even once it has no remaining events (#35). A display concern
|
||||
* applied in the composition (after past-event filtering), so it rides
|
||||
* alongside the data state like [pastEventDisplay] rather than re-querying.
|
||||
*/
|
||||
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = true,
|
||||
)
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
|
||||
private val todayDate: LocalDate
|
||||
@@ -80,7 +95,7 @@ class AgendaViewModel @Inject constructor(
|
||||
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
||||
|
||||
val state: StateFlow<AgendaUiState> =
|
||||
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart ->
|
||||
combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart ->
|
||||
AgendaParams(
|
||||
anchor = anchor,
|
||||
range = override ?: settings.range,
|
||||
@@ -142,15 +157,13 @@ class AgendaViewModel @Inject constructor(
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): AgendaUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) }
|
||||
val anchor = params.anchor
|
||||
val days = groupAgendaDays(anchor, instances, zone)
|
||||
val rangeEnd = anchor.plus(
|
||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
|
||||
return AgendaUiState.Success(
|
||||
anchor = anchor,
|
||||
today = todayDate,
|
||||
@@ -159,6 +172,7 @@ class AgendaViewModel @Inject constructor(
|
||||
rangeIsOverride = params.rangeIsOverride,
|
||||
rangeEnd = rangeEnd,
|
||||
showRangeBar = params.showRangeBar,
|
||||
zone = zone,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleAgendaEvents
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/** How far ahead the agenda preview's stand-in window runs. */
|
||||
private const val PREVIEW_WINDOW_DAYS = 14
|
||||
|
||||
/**
|
||||
* A live, scaled-down Agenda view for the onboarding view chooser. Today and
|
||||
* the day headers are real; only the events are stand-ins.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AgendaViewPreview(
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
val state = remember(today, zone) { sampleAgendaState(today, zone) }
|
||||
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
AgendaContent(
|
||||
state = state,
|
||||
// Stand-in events are all upcoming, so the past-event preference
|
||||
// cannot change what the preview shows — keep it at the default.
|
||||
pastDisplay = PastEventDisplay.SHOW,
|
||||
showToday = true,
|
||||
onRetry = {},
|
||||
onEventClick = {},
|
||||
onOpenDay = {},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample agenda state, grouped through the same helper the live view uses. */
|
||||
private fun sampleAgendaState(today: LocalDate, zone: TimeZone): AgendaUiState.Success {
|
||||
val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY)
|
||||
return AgendaUiState.Success(
|
||||
anchor = today,
|
||||
today = today,
|
||||
days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone),
|
||||
range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS),
|
||||
rangeIsOverride = false,
|
||||
rangeEnd = windowEnd,
|
||||
// The range bar belongs to the screen, not the list — the preview shows
|
||||
// the list only.
|
||||
showRangeBar = false,
|
||||
zone = zone,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import android.net.Uri
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import java.time.LocalDate
|
||||
|
||||
// SAF mime filter for the restore picker. Source apps hand `.ics` files out
|
||||
// under several mimes, so accept the common set.
|
||||
private val RESTORE_MIME_TYPES = arrayOf(
|
||||
"text/calendar",
|
||||
"application/octet-stream",
|
||||
"text/plain",
|
||||
)
|
||||
|
||||
/**
|
||||
* Backup & restore (#69): `.ics` export and restore for local calendars, with
|
||||
* optional automatic backup. Shares [CalendarsViewModel] with the manager.
|
||||
*
|
||||
* A full-screen destination hoisted in `CalendarHost`; [onBack] pops it,
|
||||
* [onImport] hands a picked file to the app's normal .ics import flow.
|
||||
*/
|
||||
@Composable
|
||||
fun BackupScreen(
|
||||
onBack: () -> Unit,
|
||||
onImport: (Uri) -> Unit,
|
||||
visible: Boolean = true,
|
||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.backupState.collectAsStateWithLifecycle()
|
||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Exports everything eligible (null); the per-calendar selector owns its
|
||||
// own launcher.
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri -> uri?.let { viewModel.exportBackup(it, null) } }
|
||||
var showExportPicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Restore runs the picked file through the normal .ics import flow.
|
||||
val openBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
) { uri -> uri?.let(onImport) }
|
||||
|
||||
// The VM persists the write grant so background runs can keep writing.
|
||||
val pickFolder = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri -> uri?.let(viewModel::setAutoBackupFolder) }
|
||||
var showInterval by remember { mutableStateOf(false) }
|
||||
|
||||
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||
LaunchedEffect(backupResult) {
|
||||
when (val r = backupResult) {
|
||||
is BackupResult.Success -> {
|
||||
snackbarHostState.showSnackbar(
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
|
||||
),
|
||||
)
|
||||
viewModel.consumeBackupResult()
|
||||
}
|
||||
BackupResult.Failure -> {
|
||||
snackbarHostState.showSnackbar(backupFailedText)
|
||||
viewModel.consumeBackupResult()
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_backup),
|
||||
onBack = onBack,
|
||||
// Loading and failure fill the screen and centre themselves, which they
|
||||
// can only do in an unscrolled column — the scrolling one measures them
|
||||
// against an unbounded height and leaves them hanging under the header.
|
||||
scrollable = state is BackupUiState.Ready,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
when (val s = state) {
|
||||
BackupUiState.Loading -> BackupLoading()
|
||||
is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack)
|
||||
is BackupUiState.Ready -> BackupContent(
|
||||
exportable = s.exportable,
|
||||
canImport = s.canImport,
|
||||
autoBackup = autoBackup,
|
||||
viewModel = viewModel,
|
||||
onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||
onPickFolder = { runCatching { pickFolder.launch(null) } },
|
||||
onExportAll = {
|
||||
runCatching { createBackup.launch(defaultBackupName()) }
|
||||
},
|
||||
onExportPick = { showExportPicker = true },
|
||||
onEditInterval = { showInterval = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Gated on Ready rather than resetting the flag when the state leaves it:
|
||||
// flipping it here would be a write during composition.
|
||||
(state as? BackupUiState.Ready)?.let { ready ->
|
||||
if (showExportPicker) {
|
||||
ExportCalendarPicker(
|
||||
calendars = ready.exportable,
|
||||
onExport = viewModel::exportBackup,
|
||||
onDismiss = { showExportPicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showInterval) {
|
||||
BackupIntervalDialog(
|
||||
currentMinutes = autoBackup.intervalMinutes,
|
||||
onConfirm = viewModel::setAutoBackupIntervalMinutes,
|
||||
onDismiss = { showInterval = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The screen's loading state, centred in the scaffold's content column. */
|
||||
@Composable
|
||||
private fun BackupLoading() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
/** Default file name for a one-shot export. */
|
||||
private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.ics"
|
||||
|
||||
/**
|
||||
* The working screen: export (when there are local calendars to export), restore,
|
||||
* and the automatic-backup block. Restore is always on screen — when nothing can
|
||||
* receive the events it says so and routes to the calendar manager rather than
|
||||
* disappearing, which is what #304 reported as an invisible button.
|
||||
*/
|
||||
@Composable
|
||||
private fun BackupContent(
|
||||
exportable: List<CalendarSource>,
|
||||
canImport: Boolean,
|
||||
autoBackup: AutoBackupUiState,
|
||||
viewModel: CalendarsViewModel,
|
||||
onRestore: () -> Unit,
|
||||
onPickFolder: () -> Unit,
|
||||
onExportAll: () -> Unit,
|
||||
onExportPick: () -> Unit,
|
||||
onEditInterval: () -> Unit,
|
||||
) {
|
||||
val manageCalendars = LocalManageCalendars.current
|
||||
// Restore never depends on export eligibility, and never disappears: when no
|
||||
// calendar can receive events it explains that and offers the way to fix it.
|
||||
val restoreSummary = if (canImport) {
|
||||
stringResource(R.string.calendars_restore_hint)
|
||||
} else {
|
||||
stringResource(R.string.calendars_restore_unavailable)
|
||||
}
|
||||
val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore)
|
||||
|
||||
if (exportable.isEmpty()) {
|
||||
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||
HintText(restoreSummary)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
position = Position.Alone,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = onRestoreClick,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_backup_action),
|
||||
position = Position.Top,
|
||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||
// A single exportable calendar skips the selector.
|
||||
onClick = if (exportable.size == 1) onExportAll else onExportPick,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
summary = restoreSummary,
|
||||
position = Position.Middle,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = onRestoreClick,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup),
|
||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||
trailing = {
|
||||
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
|
||||
},
|
||||
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
|
||||
)
|
||||
if (autoBackup.enabled) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_folder),
|
||||
summary = rememberFolderName(autoBackup.folderUri)
|
||||
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||
position = Position.Middle,
|
||||
onClick = onPickFolder,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = onEditInterval,
|
||||
)
|
||||
HintText(backupStatusText(autoBackup.status))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
||||
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||
* the picked file with the chosen calendar ids.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExportCalendarPicker(
|
||||
calendars: List<CalendarSource>,
|
||||
onExport: (Uri, Set<Long>?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Deliberately not keyed on [calendars]: that list is observer-driven, so
|
||||
// keying it would reset the user's de-selections on every provider re-emit.
|
||||
var selected by rememberSaveable(
|
||||
stateSaver = listSaver(
|
||||
save = { it.toList() },
|
||||
restore = { it.toSet() },
|
||||
),
|
||||
) {
|
||||
mutableStateOf(calendars.map { it.id }.toSet())
|
||||
}
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
onExport(uri, selected)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.calendars_export_title),
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
HintText(stringResource(R.string.calendars_export_hint))
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id in selected
|
||||
GroupedRow(
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = calendar.description,
|
||||
position = positionOf(index, calendars.size),
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { checked ->
|
||||
selected = if (checked) selected + calendar.id else selected - calendar.id
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
selected = if (isSelected) selected - calendar.id else selected + calendar.id
|
||||
},
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
},
|
||||
enabled = selected.isNotEmpty(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.calendars_export_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
||||
@Composable
|
||||
private fun rememberFolderName(uriString: String?): String? {
|
||||
val context = LocalContext.current
|
||||
return remember(uriString) {
|
||||
uriString?.let {
|
||||
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
|
||||
@Composable
|
||||
private fun backupIntervalLabel(minutes: Long): String {
|
||||
val duration = when {
|
||||
minutes % MINUTES_PER_WEEK == 0L ->
|
||||
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
|
||||
minutes % MINUTES_PER_DAY == 0L ->
|
||||
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
|
||||
minutes % 60L == 0L ->
|
||||
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
|
||||
else ->
|
||||
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
|
||||
}
|
||||
return stringResource(R.string.calendars_auto_backup_every, duration)
|
||||
}
|
||||
|
||||
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
|
||||
@Composable
|
||||
private fun backupStatusText(status: BackupStatus): String {
|
||||
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
||||
val relative = DateUtils.getRelativeTimeSpanString(
|
||||
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
|
||||
).toString()
|
||||
return if (status.lastSuccess) {
|
||||
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
||||
} else {
|
||||
stringResource(R.string.calendars_auto_backup_status_failed, relative)
|
||||
}
|
||||
}
|
||||
|
||||
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
|
||||
@Composable
|
||||
private fun BackupIntervalDialog(
|
||||
currentMinutes: Long,
|
||||
onConfirm: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Pick the largest unit the current value divides into.
|
||||
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
|
||||
val units = stringArrayResource(R.array.backup_interval_units).toList()
|
||||
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
|
||||
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
|
||||
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
|
||||
text = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
|
||||
Spacer(Modifier.width(12.dp))
|
||||
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_auto_backup_interval_min),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
|
||||
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
|
||||
onDismiss()
|
||||
}) { Text(stringResource(R.string.reminder_custom_set)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private const val MINUTES_PER_DAY = 1_440L
|
||||
private const val MINUTES_PER_WEEK = 10_080L
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||
|
||||
/**
|
||||
* State of the Backup & restore screen (#69). Three states, because the calendar
|
||||
* list it is derived from has all three: it arrives empty, it can throw, and
|
||||
* "loaded but nothing to offer" is a real outcome the screen used to render as a
|
||||
* blank page (#304).
|
||||
*/
|
||||
sealed interface BackupUiState {
|
||||
data object Loading : BackupUiState
|
||||
data class Failure(val reason: FailureReason) : BackupUiState
|
||||
|
||||
/**
|
||||
* At least one half of the screen works. [exportable] may be empty (restore
|
||||
* only) and [canImport] may be false (export only), but never both — that is
|
||||
* a [Failure].
|
||||
*/
|
||||
data class Ready(
|
||||
val exportable: List<CalendarSource>,
|
||||
val canImport: Boolean,
|
||||
) : BackupUiState
|
||||
}
|
||||
|
||||
/**
|
||||
* What the screen can offer for this calendar list.
|
||||
*
|
||||
* Export covers the app's own local calendars; managed special-dates mirrors are
|
||||
* rebuilt from contacts, so they are excluded. Restore can target anything the
|
||||
* import picker offers ([isEventTarget]).
|
||||
*
|
||||
* A failure is raised only when *neither* is possible — deliberately not
|
||||
* [calendarListFailure] on its own, which would call an all-hidden device a
|
||||
* failure while its local calendars are still perfectly exportable. When both
|
||||
* halves are dead the list itself usually says why (no calendars, everything
|
||||
* switched off); [FailureReason.NoImportTarget] covers the remaining case, where
|
||||
* calendars exist and are visible but every one of them is read-only, managed or
|
||||
* not synced to the device.
|
||||
*/
|
||||
fun backupUiState(calendars: List<CalendarSource>): BackupUiState {
|
||||
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||
val canImport = calendars.any { it.isEventTarget }
|
||||
if (exportable.isEmpty() && !canImport) {
|
||||
return BackupUiState.Failure(
|
||||
calendarListFailure(calendars) ?: FailureReason.NoImportTarget,
|
||||
)
|
||||
}
|
||||
return BackupUiState.Ready(exportable = exportable, canImport = canImport)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user