Compare commits

..

79 Commits

Author SHA1 Message Date
2cd1ebd215 fix(deps): update composebom to v2026.08.00 2026-08-24 05:02:45 +00:00
Jean-Luc Makiola
5b099714f6 2.19.3 — Czech, Japanese and Russian in the language picker (#231)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 17m33s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m2s
Renovate / renovate (push) Successful in 2m24s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/231
2026-08-22 11:18:37 +02:00
163d821d1f 2.19.3 — Czech, Japanese and Russian in the language picker
Surface the community translations that landed via Weblate but were never
listed in locales_config.xml. European Portuguese stays out: it has one
translated string.
2026-08-22 11:10:06 +02:00
Jean-Luc Makiola
fe98b1ed66 Translations update from Weblate (#223)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/223
2026-08-22 11:03:17 +02:00
James Trivette
c0270ab3a0 Translated using Weblate (Czech)
Currently translated at 36.0% (207 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-08-21 13:13:33 +00:00
James Trivette
6019d6c0b8 Translated using Weblate (Czech)
Currently translated at 33.7% (194 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-08-21 05:51:15 +00:00
James Trivette
19bc5967a4 Translated using Weblate (Czech)
Currently translated at 33.6% (193 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-08-21 05:51:05 +00:00
ame0011
ddab3454bb Translated using Weblate (Japanese)
Currently translated at 13.9% (80 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-08-20 18:48:25 +00:00
ame0011
247c9ee6cd Translated using Weblate (Japanese)
Currently translated at 11.1% (64 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-08-20 16:29:43 +00:00
Bernardo Krzysczak
6be170cae2 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pt_BR/
2026-08-20 16:29:42 +00:00
James Trivette
8133584f1e Translated using Weblate (Czech)
Currently translated at 16.3% (94 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-08-20 09:52:46 +00:00
Weblate
4cdb42afba Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-08-20 05:43:16 +00:00
James Trivette
19a4c05e93 Added translation using Weblate (Czech) 2026-08-20 05:43:16 +00:00
ame0011
aba06affbf Translated using Weblate (Japanese)
Currently translated at 9.4% (54 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-08-20 05:43:16 +00:00
ame0011
2b132a5b7a Translated using Weblate (Japanese)
Currently translated at 4.3% (25 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-08-19 16:12:28 +00:00
Weblate
52ecce03b8 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-08-19 09:41:07 +00:00
ame0011
0eb76db12d Added translation using Weblate (Japanese) 2026-08-19 09:41:07 +00:00
Jean-Luc Makiola
d03985eada Release 2.19.2 (#216)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 16m50s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m34s
Release 2.19.2 — `versionName` 2.19.2 / `versionCode` 21902. Merging this cuts the release.

A meeting you declined is struck through everywhere it appears and no longer schedules a reminder (#180). Tapping an event in month view opens the event instead of its day, in every month style (#187). An edit shows immediately when the same occurrence is re-opened (#196).

The month widget's arrows died after a couple of taps: the grid serialised ~740 views and one update ran to 450–540 KB, which overran the launcher's async binder buffer and made Android drop the whole widget host — killing updates for every widget on the home screen. The grid is down to 192 views, and `SizeMode.Exact` is kept so a resized widget reflows instead of clipping (#214). Widget taps now redraw the tapped widget by its own id rather than `updateAll`, whose provider lookup is empty in a cold-woken process (#18).

Jump-to-today in seamless weeks lands on the current week row (#191), and the day and week timelines got an end inset (#192).

Two follow-ups beyond the issues: the detail re-read keeps the loaded content on screen instead of blinking back to the skeleton, and a month chip tap with no recorded touch down (TalkBack, D-pad) no longer resolves to the previous tap's chip.

Release notes: `CHANGELOG.md` 2.19.2 section and `fastlane/metadata/android/en-US/changelogs/21902.txt` (471 chars).

Closes #18
Closes #180
Closes #187
Closes #191
Closes #192
Closes #196
Closes #214

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/216
2026-08-17 21:54:46 +02:00
Jean-Luc Makiola
34108b9c50 Translations update from Weblate (#179)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/179
2026-08-17 16:54:37 +02:00
49a0c114c2 chore(deps): update ksp monorepo to v2.3.11 (#201)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/201
2026-08-17 16:53:19 +02:00
Weblate
b1ce6f85cc Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-08-16 10:49:54 +00:00
Sup99999
ca5f87af11 Translated using Weblate (Arabic)
Currently translated at 90.0% (517 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-08-16 10:49:53 +00:00
chip
7445092767 Translated using Weblate (Russian)
Currently translated at 22.6% (130 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ru/
2026-08-15 10:22:21 +00:00
Sup99999
5ef66120bf Translated using Weblate (Arabic)
Currently translated at 86.5% (497 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-08-13 20:22:23 +00:00
chip
bda86cd73d Translated using Weblate (Russian)
Currently translated at 11.6% (67 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ru/
2026-08-13 20:22:23 +00:00
Weblate
56953b0c50 Translated using Weblate (Italian)
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-08-13 20:22:23 +00:00
Sam Gamgee
7c8cd1e145 Translated using Weblate (Italian)
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-08-13 20:22:22 +00:00
Sup99999
bce36956d1 Translated using Weblate (Arabic)
Currently translated at 86.1% (493 of 572 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-08-12 06:31:32 +00:00
Jean-Luc Makiola
e0fdadc7ca 2.19.1 — drag to reschedule can be turned off (#176)
All checks were successful
Renovate / renovate (push) Successful in 3m1s
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 16m19s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 2m29s
Release branch for 2.19.1. Merging this cuts the release — versionName is bumped to 2.19.1 (versionCode 21901), which is what release.yaml picks up.

### What's in it

- **Drag to reschedule can be turned off** (#173). Settings → Views → *Drag to reschedule*, on by default. Turned off, no event block in the month, week or day view registers a drag gesture. The gate is `EventMoveScope.dragEnabled` rather than the scope itself, so the screen-reader "Move event" action — which only opens the edit form — stays available either way. The setting reads as off until the stored value has actually loaded, so a cold start can't hand out a drag before the preference is known.

### Release prep

- CHANGELOG 2.19.1 section written.
- Per-version "What's New" hand-written for all 12 store locales (`ar`, `de-DE`, `en-GB`, `en-US`, `es-ES`, `fr-FR`, `it-IT`, `pl-PL`, `pt-BR`, `pt-PT`, `ru-RU`, `zh-CN`).
- `lint test assembleDebug` and `check_translations.py` green locally.
- main is one commit ahead (Weblate #175, translations only) and merges clean — not merged into the branch, it comes along with this one.

Closes #173

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/176
2026-08-11 18:20:09 +02:00
Jean-Luc Makiola
136167b54c Translations update from Weblate (#177)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 26s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/177
2026-08-11 17:56:49 +02:00
02fd599e6b Translated using Weblate (Portuguese (Brazil))
Currently translated at 32.3% (185 of 572 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pt_BR/
2026-08-11 15:55:09 +00:00
b0412a96f3 Translated using Weblate (German)
Currently translated at 100.0% (572 of 572 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-08-11 15:55:09 +00:00
Weblate
06dbb89868 Translated using Weblate (German)
Currently translated at 100.0% (572 of 572 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-08-11 15:55:09 +00:00
Jean-Luc Makiola
b002f45f29 Translations update from Weblate (#175)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/175
2026-08-11 17:34:40 +02:00
Weblate
c1eb702384 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-08-11 15:20:07 +00:00
Anonymous
307517e85a Translated using Weblate (Polish)
Currently translated at 85.6% (459 of 536 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-08-11 15:20:04 +00:00
Anonymous
a8ba227e8b Translated using Weblate (Spanish)
Currently translated at 79.4% (426 of 536 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/es/
2026-08-11 15:19:55 +00:00
Weblate
cbfdfb9710 Translated using Weblate (German)
Currently translated at 82.8% (444 of 536 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-08-11 15:19:53 +00:00
d85003416e Translated using Weblate (German)
Currently translated at 82.8% (444 of 536 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-08-11 15:19:52 +00:00
Anonymous
61c516149e Translated using Weblate (German)
Currently translated at 82.8% (444 of 536 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-08-11 15:19:52 +00:00
Bernardo Krzysczak
560225cb8c Translated using Weblate (Portuguese (Brazil))
Currently translated at 3.1% (17 of 536 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pt_BR/
2026-08-11 15:19:48 +00:00
Anonymous
2e8367afd4 Translated using Weblate (Portuguese (Brazil))
Currently translated at 3.1% (17 of 536 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pt_BR/
2026-08-11 15:19:47 +00:00
8c40109341 Translated using Weblate (Portuguese)
Currently translated at 1.4% (8 of 536 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pt/
2026-08-11 15:19:42 +00:00
Anonymous
4259c996b6 Translated using Weblate (Portuguese)
Currently translated at 1.4% (8 of 536 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pt/
2026-08-11 15:19:42 +00:00
d4218227bd Translated using Weblate (Russian)
Currently translated at 11.9% (64 of 536 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ru/
2026-08-11 15:19:37 +00:00
Anonymous
6a4c5084a5 Translated using Weblate (Russian)
Currently translated at 11.9% (64 of 536 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ru/
2026-08-11 15:19:37 +00:00
Anonymous
e38b9cde1f Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 22.5% (121 of 536 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/zh_Hans/
2026-08-11 15:19:30 +00:00
Anonymous
dbae8b5bcf Translated using Weblate (Arabic)
Currently translated at 81.9% (439 of 536 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-08-11 15:19:25 +00:00
Anonymous
2d06b7ad32 Translated using Weblate (French)
Currently translated at 73.1% (392 of 536 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/fr/
2026-08-11 15:19:13 +00:00
Anonymous
10a674deba Translated using Weblate (Italian)
Currently translated at 80.4% (431 of 536 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-08-11 15:19:05 +00:00
Jean-Luc Makiola
93b9aa8f34 2.19.0 — drag to reschedule, bulk delete from search, onboarding wizard (#172)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 12s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 14m58s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m2s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/172
2026-08-10 18:40:55 +02:00
ca826737ed fix(deps): update hilt to v2.60.1 (#158)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/158
2026-08-10 18:08:18 +02:00
5365cc67eb fix(deps): update material3 (alpha) to v1.5.0-alpha25 (#138)
This PR contains the following updates:

| Package | Type | Change | Age | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|---|
| [androidx.compose.material3:material3](https://developer.android.com/jetpack/androidx/releases/compose-material3#1.4.0) ([source](https://cs.android.com/androidx/platform/frameworks/support)) | dependencies | `1.5.0-alpha24` → `1.5.0-alpha25` | 11 d | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.material3:material3/1.5.0-alpha24/1.5.0-alpha25?slim=true) |

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMzIuMCIsInVwZGF0ZWRJblZlciI6IjQzLjIzMi4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->

Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/138
2026-08-10 18:07:43 +02:00
Jean-Luc Makiolas Weblate Bot
25e3640ba0 Translations update from Weblate (#170)
Translations update from [Weblate](https://weblate.dev.jeanlucmakiola.de) for [Calendula/Strings](https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/).

Current translation status:

![Weblate translation status](https://weblate.dev.jeanlucmakiola.de/widget/calendula/strings/horizontal-auto.svg)

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Admin <business@jeanlucmakiola.de>
Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Pedro <pedrolojo@gmail.com>
Co-authored-by: Bernardo Krzysczak <berkrz@proton.me>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/170
2026-08-10 18:04:24 +02:00
3f85bac74e chore(deps): update ksp monorepo to v2.3.10 (#137)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Renovate / renovate (push) Successful in 3m9s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/137
2026-08-08 13:51:11 +02:00
5b83b9da73 fix(deps): update kotlinx-coroutines monorepo to v1.11.0 (#160)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/160
2026-08-08 13:50:44 +02:00
3c8c555b94 fix(deps): update hiltnavigationcompose to v1.4.0 (#159)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/159
2026-08-08 13:50:15 +02:00
a6a6c0c714 fix(deps): update documentfile to v1.1.0 (#157)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/157
2026-08-08 13:48:29 +02:00
Jean-Luc Makiola
66dbdc8913 2.18.1 — create at the tapped hour after a zoom (#155)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 9s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 14m28s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m4s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/155
2026-08-06 20:09:50 +02:00
Jean-Luc Makiolas Weblate Bot
6a125b7c73 Translations update from Weblate (#136)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 32s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/136
2026-08-06 19:40:09 +02:00
Jean-Luc Makiolas Weblate Bot
44233cff35 Translations update from Weblate (#130)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 30s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Renovate / renovate (push) Successful in 1m37s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/130
2026-08-02 17:45:31 +02:00
Jean-Luc Makiolas Weblate Bot
5764bd889b Translations update from Weblate (#129)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 30s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Translations update from [Weblate](https://weblate.dev.jeanlucmakiola.de) for [Calendula/Strings](https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/).

Current translation status:

![Weblate translation status](https://weblate.dev.jeanlucmakiola.de/widget/calendula/strings/horizontal-auto.svg)

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Admin <business@jeanlucmakiola.de>
Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Co-authored-by: augustin64 <me.git+calendula@augustin64.fr>
Co-authored-by: Thomas Tref <Trefthomas33@gmail.com>
Co-authored-by: Xavier <bxavier52@laposte.net>
Co-authored-by: Weblate <noreply@weblate.org>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/129
2026-08-01 15:42:13 +02:00
Jean-Luc Makiola
a8a83a39d0 2.18.0 (#126)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 13m15s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m34s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/126
2026-07-31 22:22:10 +02:00
Jean-Luc Makiolas Weblate Bot
9557ca73ed Translations update from Weblate (#125)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Translations update from [Weblate](https://weblate.dev.jeanlucmakiola.de) for [Calendula/Strings](https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/).

Current translation status:

![Weblate translation status](https://weblate.dev.jeanlucmakiola.de/widget/calendula/strings/horizontal-auto.svg)

Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Admin <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/125
2026-07-31 22:06:12 +02:00
Jean-Luc Makiola
9767fbbbaf Fix the sidebar's back gesture and alignment (#114) (#121)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
- Back now closes the drawer instead of the app: a `BackHandler` inside the sheet, enabled while the drawer is open. The four host screens (Month/Week/Day/Agenda) pass their `drawerState` in.
- Header and section labels use `GroupedListInset` instead of a hardcoded 28.dp, so "Calendula", "View" and "Calendars" share the left edge of the rows below them — same as the Settings screens. The account labels and placeholders in `CalendarFilterList` follow.
- Plain leading icons now sit in a 40.dp box, matching `CalendarColorChip`'s footprint, so view rows, jump-to-date, Settings and the calendar rows all line up on one vertical axis.

Closes #114

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/121
2026-07-31 20:50:13 +02:00
Jean-Luc Makiola
9054742503 Adjustable vertical scale for the week and day timeline (#56) (#122)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/122
2026-07-31 20:49:56 +02:00
Jean-Luc Makiola
3d62036d79 Drive the system bar icons from the in-app light/dark choice (#70) (#120)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/120
2026-07-31 20:31:50 +02:00
Jean-Luc Makiolas Weblate Bot
34a8e91ea5 Translations update from Weblate (#119)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Translations update from [Weblate](https://weblate.dev.jeanlucmakiola.de) for [Calendula/Strings](https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/).

Current translation status:

![Weblate translation status](https://weblate.dev.jeanlucmakiola.de/widget/calendula/strings/horizontal-auto.svg)

Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/119
2026-07-31 20:28:07 +02:00
Jean-Luc Makiola
0097a9c534 Fix the Play upload's AAB path (#110)
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Failing after 5s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/110
2026-07-31 12:37:13 +02:00
Jean-Luc Makiola
4e51e33356 2.17.1 — re-cut 2.17.0 after the release pipeline dropped it (#109)
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 9s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 14m0s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 1m5s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/109
2026-07-30 22:42:58 +02:00
Jean-Luc Makiola
e8657117d6 2.17.0: reminders Calendula delivers itself, one visibility model, and a Settings you can navigate (#108)
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Failing after 6m29s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/108
2026-07-30 22:14:57 +02:00
Jean-Luc Makiola
d5c53df6b5 Publish the release bundle to Google Play (#84) (#100)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/100
2026-07-30 13:59:43 +02:00
Jean-Luc Makiola
2f4e05899a Make the Obtainium badge add the app in one tap (#99)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/99
2026-07-30 11:39:06 +02:00
06653bf5b1 fix(deps): update composebom to v2026.06.01 (#95)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/95
2026-07-30 11:27:24 +02:00
e952393116 fix(deps): update test dependencies (#92)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/92
2026-07-30 11:27:09 +02:00
384ba39cf9 fix(deps): update material3 (alpha) to v1.5.0-alpha24 (#91)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
fix(deps): update material3 (alpha) to v1.5.0-alpha24 #91
2026-07-30 11:26:18 +02:00
Jean-Luc Makiola
3fca28810b Don't run the Android build for forge-housekeeping changes (#98)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
CI already skips the Android build for docs-only pull requests, and that part works — a README-only PR reports green in ~11s. The skip-list was just too narrow to catch a realistic docs PR.

PR #96 is the example: a contributing guide, docs corrections and issue templates. It ran the full lint + test + assemble four times, over exactly two files Gradle never reads — `.forgejo/ISSUE_TEMPLATE/config.yml` and `.gitignore`. Added to the list: issue templates, `.planning/`, `licenses/`, `renovate.json5`, `.gitignore`, `.gitattributes`, `.editorconfig`.

It stays a skip-list rather than a build-list, so an unfamiliar path still builds by default; the workflows themselves, `.gitmodules` and `scripts/` are deliberately not skippable. The step now also prints which files forced the build, so the next "why did my docs PR build?" is answerable from the log.

One latent bug alongside it: `github.base_ref` is normalised against a `refs/heads/` prefix. Arriving in full-ref form would fail the merge-base lookup and quietly degrade the guard into "always build" — the failure mode this PR is fixing, but permanently and invisibly.

Verified by replaying the new pattern over real history: PR #96 and #97 now skip; the forge migration (workflows), the search fix (app code) and a Weblate translation merge still build.

No issue — reported directly.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/98
2026-07-30 11:22:23 +02:00
Jean-Luc Makiola
c988e1f028 Fix the broken README screenshot and spell out the install channels (#97)
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 9s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been cancelled
The screenshot strip linked `05-edit.png`, which doesn't exist — replaced with the real agenda screenshot, and the unused day-view shot is now shown too (six screenshots, all present).

Install is now one section per channel — official F-Droid, self-hosted F-Droid repo, Codeberg release / Obtainium, Google Play (coming soon), build from source — with an overview table up front and the previously missing "Get it on Obtainium" badge in the header. The badge links to the Codeberg releases page rather than an `obtainium://` deep link, because Forgejo's markdown sanitiser strips custom URL schemes.

The Play section notes that Play builds carry Google's signing key (so switching channels needs an uninstall) and asks for closed-testing testers.

Also corrects two stale claims: the UI is no longer German-and-English only (nine translations exist), and the language list now says so.

No issue — reported directly.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/97
2026-07-30 11:22:11 +02:00
Jean-Luc Makiola
a3d136f55d Write a contributing guide, and fix what the forge move left stale (#96)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 27s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
(docs) Update all the docs to be up to date with current status quo
2026-07-30 10:40:07 +02:00
238 changed files with 14640 additions and 3417 deletions

View File

@@ -0,0 +1,30 @@
---
name: Bug report
about: Something doesn't work the way it should
title: ""
labels:
- bug
---
### What happened
### What you expected
### Steps to reproduce
1.
2.
3.
### Environment
- Calendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:
- Installed from: <!-- official F-Droid / the self-hosted repo / built from source -->
- Affected calendar: <!-- Google, CalDAV (DAVx5, Nextcloud, …), on-device/local,
subscribed/WebCal, birthdays — provider behaviour differs
a lot per account type, so this often points straight at
the cause -->
- Time zone: <!-- only if the problem involves dates or all-day events -->

View File

@@ -0,0 +1,18 @@
# Kept enabled so anything that doesn't fit the four templates still has a way
# in (the `ToDo` label exists for exactly those).
blank_issues_enabled: true
contact_links:
- name: Translate Calendula
url: https://weblate.dev.jeanlucmakiola.de/engage/calendula/
about: >-
Translations are managed on Weblate, not here — it owns every values-*
file, so a hand-edited translation gets overwritten on the next sync.
No coding needed: pick or request a language and translate in the browser.
- name: Contributing guide
url: https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
about: >-
Before opening a pull request: the issue-first workflow, which release
branch to target, how to build (there's a submodule), and the
architectural rules a change is reviewed against.

View File

@@ -5,7 +5,7 @@ title: "Crash: "
labels:
- bug
- crash
- priority:high
- priority/high
---
<!--

View File

@@ -0,0 +1,41 @@
<!--
Thanks for contributing to Calendula!
Please skim CONTRIBUTING.md if you haven't:
https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
Two things it's easy to get wrong:
• Features need a discussed issue first — an undiscussed feature PR may be
closed unmerged even when the code is good.
• Target the release branch for your issue's milestone (milestone 2.18.0 →
release/v2.18.0), not main. If you targeted main, just say so below and it
will be retargeted.
-->
### What this changes
### Why
<!-- Closes #123 — link the issue this implements or fixes. -->
### How it was tested
<!--
Which of these ran green, and anything you exercised by hand. On-device notes
are especially useful for UI changes.
./gradlew lint test assembleDebug
python3 scripts/check_translations.py
-->
### Checklist
- [ ] There's an issue for this, and (for a feature) it got a go-ahead
- [ ] Targeting the release branch for that issue's milestone — or `main`, noted above
- [ ] `./gradlew lint test assembleDebug` passes locally
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible
- [ ] No planning or design documents committed

View File

@@ -37,14 +37,29 @@ jobs:
- name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh
# Decide whether anything that affects the app build changed. Docs,
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
# Gradle work below but still report a green `ci`.
# Decide whether anything that affects the app build changed. Docs, store
# metadata, licence texts and forge housekeeping don't, so those PRs skip
# the SDK + Gradle work below but still report a green `ci`.
- name: Classify change scope
id: scope
env:
# Deliberately a skip-list, not a build-list: a path nobody thought
# about defaults to building. Only paths the Gradle build provably
# never reads belong here — note that the workflows themselves, the
# `.gitmodules` submodule pointer and `scripts/` are *not* in it.
SKIP_RE: '(\.md$|^docs/|^fastlane/|^fdroid-metadata/|^licenses/|^\.planning/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^renovate\.json5$|^LICENSE$)'
run: |
set -e
BASE="${{ github.base_ref }}"
# Normally the bare branch name; tolerate a full ref, which would
# otherwise make the merge-base lookup fail and quietly degrade this
# guard into "always build".
BASE="${BASE#refs/heads/}"
if [ -z "$BASE" ]; then
echo "No base branch on this event — running the full build to be safe."
echo "code=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Full (not --depth=1) base fetch so the merge-base is present even when
# the PR branch forked several commits back; a shallow tip has no merge
# base with a divergent branch and `git diff base...HEAD` aborts.
@@ -58,11 +73,15 @@ jobs:
fi
CHANGED=$(git diff --name-only "$MB" HEAD)
echo "Changed files:"; echo "$CHANGED"
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then
RELEVANT=$(echo "$CHANGED" | grep -vE "$SKIP_RE" || true)
if [ -n "$RELEVANT" ]; then
# Naming them makes "why did my docs PR build for four minutes?"
# answerable from the log alone.
echo "Build-relevant changes:"; echo "$RELEVANT"
echo "code=true" >> "$GITHUB_OUTPUT"
else
echo "code=false" >> "$GITHUB_OUTPUT"
echo "Docs/metadata-only change — skipping the Android build."
echo "code=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Java

View File

@@ -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:

View File

@@ -1,4 +1,4 @@
name: Release — F-Droid repo + Gitea/Codeberg release
name: Release — F-Droid repo + Gitea/Codeberg release + Play
# A release is cut by merging a release branch into main with a bumped
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
@@ -9,6 +9,12 @@ name: Release — F-Droid repo + Gitea/Codeberg release
# 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,
# without building an APK or creating a release. Used for key rotation / repo
@@ -258,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
@@ -477,3 +484,172 @@ jobs:
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
done
echo "Published $TAG to Codeberg."
# Play takes an App Bundle, not the APK, so it is a second artifact from
# the same source and the same signing config — not a repackage of the
# APK. The release key signs it, but Play only ever treats that key as the
# *upload* key: Play App Signing re-signs with Google's own key before
# delivery. A Play install and an F-Droid install therefore carry
# different signatures and cannot update each other. That divergence is a
# deliberate, documented choice (docs/RELEASING.md), not an accident.
#
# Built LAST and `continue-on-error`, both deliberately: everything above
# has already shipped by this point, and nothing Play-related may put that
# at risk. Sitting mid-job without continue-on-error, this block took the
# whole 2.17.0 release down with it — no F-Droid publish, no tag, no
# Codeberg mirror — over an artifact upload. A failure here now costs the
# Play upload and nothing else.
#
# Nothing here touches the F-Droid path: the AAB is never copied into the
# repo, never attached to a release, and its build cannot change the APK
# published above.
#
# AGP embeds the R8 mapping in the bundle's BUNDLE-METADATA, so Play gets
# deobfuscated stacktraces without a separate mapping upload.
- name: Build release AAB
if: env.IS_RELEASE == 'true'
continue-on-error: true
run: ./gradlew bundleRelease
# NOT actions/upload-artifact@v4: it runs @actions/artifact v2, which
# refuses to start whenever GITHUB_SERVER_URL is not github.com — it reads
# any other forge as an unsupported GHES instance and fails before it ever
# talks to the server (go-gitea/gitea#36024). Gitea 1.25 serves the v4
# artifact API fine; only the client-side check is wrong. This fork is that
# client with the check removed. Pinned to a commit, not the v4 branch: a
# third-party action in the signing pipeline must not change under us.
- name: Hand the AAB to the Play job
if: env.IS_RELEASE == 'true'
continue-on-error: true
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
with:
name: release-aab-${{ needs.detect.outputs.version }}
path: app/build/outputs/bundle/release/app-release.aab
if-no-files-found: error
retention-days: 14
# Google Play channel.
#
# A separate job, on purpose, running only AFTER the F-Droid publish and both
# forge releases have completed. Play is the one channel that can reject a
# perfectly good build for reasons outside the pipeline (listing rules, policy
# review, API outage, a track that needs manual promotion). Isolating it means
# such a rejection surfaces as one red job next to a release that already
# shipped everywhere else, instead of failing the workflow that publishes it.
#
# Not a `container:` job even though a fastlane image exists: act_runner does
# not provide node inside custom job containers, so JavaScript actions
# (checkout, download-artifact) can't run there. The Renovate job gets away
# with a container because its only step is a shell command. Ruby is installed
# the same way sshpass, jq and fdroidserver are in the job above.
play:
needs: [detect, release]
# workflow_dispatch is the F-Droid re-sign recovery path — it must never
# touch Play, so gate on a real release only.
if: needs.detect.outputs.is_release == 'true'
runs-on: docker
env:
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
# Where the bundle lands. `internal` by default so a release reaches
# testers rather than the public, and promotion to production stays a
# deliberate human action in the Play Console — the same posture as
# holding UI releases for on-device review. Override with the PLAY_TRACK
# repo variable once the flow is trusted.
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'internal' }}
PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }}
# Set PLAY_DRY_RUN=true to validate the edit against the API and discard
# it instead of committing — used to rehearse the first upload.
PLAY_DRY_RUN: ${{ vars.PLAY_DRY_RUN || 'false' }}
BUNDLE_PATH: vendor/bundle
steps:
- name: Checkout
uses: actions/checkout@v4
# Skip cleanly (not fatally) when Play isn't configured yet, so the rest
# of the release pipeline keeps working during setup — same contract as
# the Codeberg mirror step.
- name: Write the Play service-account key
id: key
env:
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
run: |
set -euo pipefail
if [ -z "${PLAY_SERVICE_ACCOUNT_JSON:-}" ]; then
echo "PLAY_SERVICE_ACCOUNT_JSON not set — skipping the Play upload."
echo "configured=false" >> "$GITHUB_OUTPUT"
exit 0
fi
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
# Fail here, with a clear message, rather than inside fastlane: a
# mangled multi-line secret is the likeliest setup mistake.
python3 -c "import json,sys; d=json.load(open('play-service-account.json')); sys.exit(0 if d.get('type')=='service_account' else 1)" \
|| { echo "PLAY_SERVICE_ACCOUNT_JSON is not a valid service-account JSON." >&2; exit 1; }
echo "configured=true" >> "$GITHUB_OUTPUT"
# Same GHES-detection problem as the upload side, same fix — see the
# handoff step in the release job.
- name: Download the AAB
if: steps.key.outputs.configured == 'true'
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 # v4
with:
name: release-aab-${{ needs.detect.outputs.version }}
path: dist
- name: Install Ruby
if: steps.key.outputs.configured == 'true'
run: |
set -euo pipefail
SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
$SUDO apt-get update
# ruby-dev + build-essential: several of fastlane's dependencies build
# native extensions.
$SUDO apt-get install -y ruby-full ruby-dev build-essential
ruby -v
# Only the first release pays the full gem build; afterwards this restores.
- name: Cache bundled gems
if: steps.key.outputs.configured == 'true'
uses: actions/cache@v4
with:
path: vendor/bundle
key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile') }}
restore-keys: |
${{ runner.os }}-gems-
- name: Install fastlane
if: steps.key.outputs.configured == 'true'
run: |
set -euo pipefail
gem install bundler --no-document
bundle config set --local path vendor/bundle
bundle install --jobs 4
bundle exec fastlane --version
- name: Upload to Play
if: steps.key.outputs.configured == 'true'
env:
SUPPLY_JSON_KEY: play-service-account.json
# supply is chatty on a TTY-less runner otherwise.
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_HIDE_CHANGELOG: '1'
run: |
set -euo pipefail
AAB="$GITHUB_WORKSPACE/dist/app-release.aab"
# Absolute, because a lane body runs from fastlane/, not the
# workspace root — a relative path resolves against the wrong
# directory there and 2.17.1 died on exactly that.
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
bundle exec fastlane deploy \
aab:"$AAB" \
track:"$PLAY_TRACK" \
release_status:"$PLAY_RELEASE_STATUS" \
dry_run:"$PLAY_DRY_RUN"
echo "Uploaded $VERSION (code $VERSION_CODE) to the '$PLAY_TRACK' track."
# The workspace is reused between runs on a self-hosted runner, so the
# credential must not outlive the job.
- name: Shred the service-account key
if: always()
run: shred -u play-service-account.json 2>/dev/null || rm -f play-service-account.json

16
.gitignore vendored
View File

@@ -50,11 +50,27 @@ google-services.json
.DS_Store
Thumbs.db
# Editor swap/backup files
*.swp
*.swo
*~
# F-Droid local artifacts (the pipeline generates them in CI)
/fdroid/
# KSP
.ksp/
# Google Play Developer API service-account key. Reconstructed in CI from the
# PLAY_SERVICE_ACCOUNT_JSON secret and shredded afterwards — never committed.
/play-service-account.json
# fastlane (Play uploader only — see fastlane/Fastfile)
/fastlane/report.xml
/fastlane/README.md
/vendor/bundle/
/.bundle/
Gemfile.lock
# Claude Code
/CLAUDE.md

View File

@@ -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.1v2.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`.

View File

@@ -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

View File

@@ -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 3132 (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.

View File

@@ -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.1v2.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 24: 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.1v2.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**.

View File

@@ -7,6 +7,209 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.19.3] — 2026-08-22
### Added
- **Czech, Japanese and Russian**, all three contributed as community
translations through
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
They are early — Czech covers about a third of the app, Russian a fifth,
Japanese has just started — so untranslated parts still show in English until
they fill out. Pick any of them under Settings → Language or in Android's
per-app language settings. Thanks to James Trivette (Czech), ame0011
(Japanese), and chip and Pablo (Russian); help finishing them is very welcome.
### Changed
- **Brazilian Portuguese is now complete**, covering the whole app. Thanks to
bkrz for carrying it the rest of the way.
## [2.19.2] — 2026-08-17
### Changed
- **A meeting you declined is now struck through** wherever it appears — month,
week, day, agenda, search and both widgets — and no longer schedules a
reminder. Declining an invitation in Google Calendar left the event looking
like any other in Calendula, and it still notified you about a meeting you had
said no to. It stays visible rather than disappearing: the organiser still
expects an answer from you, and the slot is still spoken for ([#180]).
### Fixed
- **Tapping an event in month view opens the event**, not the day it sits on.
Every month style is affected — page, rolling, seamless weeks — and until now
the only way to reach an event from the month was to open its day first and
find it again there. Tapping anywhere else in the cell still opens the day
([#187]).
- **An edit made to an event now shows the moment you re-open it.** Adding a
description to an event and opening it again showed the sheet as it was before
the save, because the detail was only re-read when a different occurrence was
opened ([#196]).
- **The month widget's arrows stopped working after a couple of taps.** The grid
was serialised as roughly 740 views, and one update ran to half a megabyte —
more than the launcher's buffer takes. The third update overran it, and Android
responded by dropping the whole widget host, which killed updates for *every*
widget on the home screen, ours and other apps', until the launcher rebound.
The grid now draws 192 views, and a resized widget reflows to its new size
instead of clipping ([#214]).
- Tapping a widget's refresh or month arrows redraws that widget by its own id
instead of asking Android to update all of them, which does nothing in a
process the tap has just woken from cold ([#18]).
- **Jump-to-today in seamless weeks lands on the current week** instead of
leaving a sliver of the previous row on screen ([#191]).
- Day and week view no longer run their events flush against the right edge
([#192]).
## [2.19.1] — 2026-08-11
### Added
- **Drag to reschedule can be turned off.** Settings → Views → *Drag to
reschedule*, on by default. Off, no event can be picked up in the month, week
or day view — for anyone who kept moving appointments by accident while
scrolling ([#173]).
- An early Brazilian Portuguese translation. Calendula has started speaking
Brazilian Portuguese, contributed as a community translation through
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
About a third of the app is covered, so the rest still shows in English — you
can pick it under Settings → Language or in Android's per-app language
settings. Thanks to
[bkrz](https://weblate.dev.jeanlucmakiola.de/user/bkrz/) for getting it
started; help finishing it is very welcome.
### Fixed
- **The language picker forgot which language was picked.** Android hands the
applied language back with its script filled in — Simplified Chinese goes in
as `zh-CN` and comes out as `zh-Hans-CN` — so after a restart no entry in
Settings → Language showed as selected, and the row above it named the
language the long way round. Only languages that imply a script were
affected.
## [2.19.0] — 2026-08-10
### Added
- **Drag an event to reschedule it.** Press and hold an event in the month,
week or day view and drop it on another time or day — the only way to move an
appointment used to be opening it and editing its start and end by hand. The
block follows your finger, the timeline scrolls when you carry it to an edge,
and the event's length is preserved: a 90-minute meeting dropped on 09:00 ends
at 10:30. In week and day view the drop snaps to 15-minute steps; in month
view it moves the event to the day you dropped it on and leaves the time
alone. A confirmation names the new slot and offers **Undo** wherever the move
can be taken back in one step. Events you can't edit — read-only calendars, and
birthdays and anniversaries mirrored from your contacts — can't be picked up.
Rescheduling isn't pointer-only: a screen reader gets a **Move…** action on
every event that can be moved ([#68]).
- Dragging a **recurring** event asks what it should apply to, the same *this
event / this and following / all events* choice a save does. Where moving the
whole series would change which weekday it falls on, the rule is rewritten to
match — a weekly Monday event dropped on a Wednesday becomes a weekly
Wednesday event. Where it can't be rewritten safely, the prompt says so and
offers to move just the one occurrence, rather than quietly leaving the
series behind ([#68]).
- **Delete several search results at once.** Long-press a hit in search to enter
selection mode, tap the rest (or **Select all**), and delete the lot in one
action — finding and removing, say, every leftover event with the same title
no longer means opening them one at a time. Recurring hits in the batch ask
once for the whole selection instead of once each, results on read-only
calendars can't be selected, and the deletion is confirmed before it runs
([#80]).
### Changed
- **First launch is now a short wizard** instead of a single permission screen.
It counts its steps, each one can be stepped back out of, and beyond granting
calendar access it offers two things worth deciding once: turning on automatic
backup — chosen inline, folder picker and all, since events that live only on
the phone are lost with it — and choosing which view Calendula opens on, with
a live preview of the month, week, day and agenda layouts rather than four
names in a list. Both are skippable, everything chosen is changeable in
Settings afterwards, and the backup step is offered only where nothing
writable is being synced anywhere. Existing installs are not re-onboarded —
revoking and re-granting the calendar permission later does not restart the
wizard ([#163]).
- The one-time notice about calendars switched off for this device is now a step
in the flow rather than a dialog thrown over the app on first open.
### Fixed
- Removed the deprecated `statusBarColor` / `navigationBarColor` theme
attributes the Play Console flags on Android 15 and up, where they no longer
do anything. The launch backdrop now picks its status-bar icons from the
light/dark resource instead, so they stay legible over it either way
([#123]).
## [2.18.1] — 2026-08-06
### Fixed
- Tapping an empty slot in week or day view creates the event at the hour you
tapped, after the timeline has been zoomed. Pinching the day taller or shorter
— or changing **Hour height** in Settings — left the tap still being measured
against the old spacing, so the new event landed at some other hour entirely
([#148]).
## [2.18.0] — 2026-07-31
### Added
- A new event no longer always lasts an hour. **Settings → New event form →
Default duration** sets how long one opens, and each calendar may keep its own
length underneath — 8 hours for the calendar you keep work shifts in, 30
minutes for the one you book calls in. A calendar without its own length
follows the default, switching calendars mid-form re-stretches the event, and
setting an end time by hand keeps it. An event another app hands over with only
a start gets the default too; one that names its own end — an `.ics` file, a
duplicate — keeps that length. All-day events are unaffected ([#54]).
- Week and day view can be set to show more or less of the day at once, under
Settings → Views → Week & day → **Hour height**. **Fit whole day** sizes an
hour to your screen so all 24 hours are visible without scrolling — on a tall
phone the old fixed spacing showed only about half a day, so appointments
could sit below the fold all week. Compact and Comfortable are fixed steps
either side of the previous spacing, which stays the default. Both views share
the setting ([#56]).
- Week and day view can be **pinched** with two fingers to set the hour height
directly, anywhere between and beyond the named steps. The time under your
fingers stays put as it zooms, so you keep your place in the day. A pinched
height is remembered and appears as **Custom** in the Hour height setting, so
tapping a named step there takes you back to it ([#56]).
### Changed
- The date in the top bar is now the way to jump: tapping the month, week or day
title opens the same date picker the sidebar's **Jump to date** offers, seeded
on whatever the bar is naming. A drop-down caret marks it as tappable. The
sidebar entry stays where it is ([#57]).
- A reminder in the status bar is now Calendula's own mark — the calendar with
the bloom — instead of the generic calendar glyph it shared with the system's
date surfaces and every other calendar app. The status bar draws that icon as
a plain silhouette, so its shape is the only thing that could tell them apart
([#83]).
- Event blocks now only draw text they can draw whole. One too short for a full
line shows no title rather than a sliced one, a block that cannot fit both its
title and its time keeps the title, and a block too narrow to hold more than a
syllable stays on one ellipsised line instead of stacking letters down the
block. Tapping and the spoken description are unchanged ([#56]).
- Calendar colours are reworked so text on an event is always readable. They
were shaped by a brightness setting that does not match what the eye sees, so
whether a block got dark or light text depended on which hue you happened to
pick — an orange calendar took dark text while a red one beside it took light
— and colours landing in between were hard to read either way. Each colour now
keeps its hue and is moved clear of that middle: most become deep blocks with
light text, while naturally pale colours such as yellow stay pale and take
dark text, rather than being forced into a muddy brown. Dots, stripes and
icons are tuned separately from blocks, so they stay visible against the
background instead of sharing a colour meant to sit behind text. The setting
is now called **Harmonise calendar colours**; turning it off still shows the
raw colours from your calendar source ([#21], [#36]).
### Fixed
- The back gesture closes the sidebar instead of the app. With the drawer open,
swiping back left Calendula altogether rather than putting the sidebar away
([#114]).
- The sidebar lines up. "Calendula", "View" and "Calendars" now share the left
edge of the rows under them, and the view, jump-to-date, Settings and calendar
rows sit on one vertical axis instead of each icon finding its own — the same
alignment the Settings screens use ([#114]).
- The status- and navigation-bar icons follow Calendula's own light/dark choice.
Setting the app dark while the system stayed light — or the other way round —
left the clock and battery drawn for the system's theme, so they could sit
near-invisible against the app's own bar ([#70]).
## [2.17.1] — 2026-07-30
### Added
- Settings → Calendars now says what is different about a calendar instead of
leaving you to guess. Ones you can only view — a subscribed calendar, a
@@ -28,12 +231,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The calendar picker in the event form and in the .ics import screen now ends
with a **"Missing a calendar?"** row that opens Settings → Calendars, where
those marks then explain why a calendar isn't offered ([#76]).
- The agenda widget's text size is yours to set. **Settings → Agenda → Agenda
widget size** offers Small, Medium, Large and Extra large, replacing the guess
- The agenda widget's text size is yours to set. **Settings → Widgets & tiles →
Agenda widget size** offers Small, Medium, Large and Extra large, replacing the guess
the widget used to make from its own measurements. Small is what it looks like
today, so nothing changes until you turn it up ([#51]).
- A repeating event now shows you its next few dates, not just a description of
the rule. Both the preset list and the custom recurrence picker carry a
**Next:** line — "Next: 30 Jul, 6 Aug, 13 Aug" — under the rule they would
save. A phrase like "monthly" on the 31st, or "every 2 weeks on Mon & Fri",
can mean something other than it sounds like, and only the dates say so. A
rule that can never fire says that instead ([#69]).
- The event visibility options now say who they affect. **Public**, **Private**
and **Confidential** each carry a line about what other people on a shared
calendar see — the part the four words on their own leave out ([#69]).
### Changed
- **Settings has been reorganised so each setting sits where you would look for
it.** One long undifferentiated list is now three labelled groups — Look &
behaviour, Data, and App — whose rows open sub-screens and say what they do
rather than only naming themselves. Appearance, Views, New event form,
Notifications and the new Widgets & tiles are separate screens now, so the
settings for a calendar view are no longer mixed in with the ones for the
app's colours or for the home-screen widgets ([#69]).
- Settings that are hard to picture from their name now show you what they do.
The week-start picker rearranges a real month grid as you choose, the
past-events setting previews a sample agenda day for Show, Dim and Hide, the
font pickers set a specimen line in the face you are choosing, and the Agenda
range options carry the dates each one actually covers. Options that follow
the system additionally name which way they currently fall ([#69]).
- **Backup & restore** is now its own Settings entry instead of living inside
the calendar manager, where it was easy to miss — keeping a copy of your
calendars is a different question from which calendars you have. The calendar
manager keeps a row pointing to it, and nothing about how backup or automatic
backup works has changed ([#69]).
- Calendula's source code now lives on **Codeberg**, where its issues already
were. The **Source code** and **License** links in Settings → About point
there, so reporting a bug and reading the code no longer land on two different
@@ -1220,6 +1450,24 @@ automatically, with zero telemetry and no internet permission.
[#79]: https://codeberg.org/jlmakiola/calendula/issues/79
[#81]: https://codeberg.org/jlmakiola/calendula/issues/81
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
[#83]: https://codeberg.org/jlmakiola/calendula/issues/83
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69
[#54]: https://codeberg.org/jlmakiola/calendula/issues/54
[#57]: https://codeberg.org/jlmakiola/calendula/issues/57
[#56]: https://codeberg.org/jlmakiola/calendula/issues/56
[#114]: https://codeberg.org/jlmakiola/calendula/issues/114
[#148]: https://codeberg.org/jlmakiola/calendula/issues/148
[#68]: https://codeberg.org/jlmakiola/calendula/issues/68
[#80]: https://codeberg.org/jlmakiola/calendula/issues/80
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214

184
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,184 @@
# Contributing to Calendula
Calendula is a Material 3 Expressive calendar app that lives strictly on top of
Android's `CalendarContract` — no app database, no sync stack, no network access.
That constraint shapes most review comments, so
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) is worth skimming before you write
code. This file is the practical how.
**[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home** —
issues, pull requests, releases. The self-hosted Gitea instance referenced in the
release docs is build infrastructure only; there is nothing to contribute there.
Be decent to the people you meet in the tracker.
## Start with an issue
| You want to | Do this |
|---|---|
| Add a feature | **Open an issue first** and wait for a go-ahead |
| Fix a bug | Open an issue, then a pull request |
| Fix a typo, a comment, or docs | Just open the pull request |
| Add or fix a translation | **Don't** — [use Weblate](#translations) |
Features get an opinion before they get code: whether Calendula should do a
thing at all is the one decision a patch can't make. A feature PR that arrives
without a discussed issue may be closed unmerged even when the code is good —
please don't spend a weekend on one first.
Bugs are more straightforward, but still start with an issue: it's what carries
the milestone and gives the changelog something to link.
Issue templates cover bug, crash, feature and question. For a crash, let the app
do the work — **Settings → Report a problem**, or the prompt shown after a crash,
captures the stack trace and prefills the form. The report contains app, Android
and device versions plus the trace; no calendar content, no personal data.
## Which branch to target
Calendula releases by merging a version bump into `main`, so `main` is a release
trigger rather than a staging area. Work is assembled on release branches first.
Once your issue has a milestone, that milestone names your branch:
| Milestone | Target branch |
|---|---|
| `2.18.0` | `release/v2.18.0` |
Every milestone has a matching branch. If it's somehow missing, target `main` and
mention it in the PR — it will be retargeted. Don't pick an older release branch:
they're kept after shipping, so the newest one isn't necessarily yours.
## Translations
**Never edit a `values-*/strings.xml` file in a pull request** — German included.
Translations are owned by a self-hosted Weblate that writes to this repository
directly, and a hand-edit is overwritten on the next sync.
**[Translate Calendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
picks it up and offers it to translators. Partial translations are expected and
fine — missing keys are informational. Stale and orphaned keys are not, so run
```sh
python3 scripts/check_translations.py
```
before pushing. It reports those more clearly than lint's `MissingTranslation`
does.
## Build & test
```sh
git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git
```
The `floret-kit` submodule is a composite build compiled from source. An
existing clone needs `git submodule update --init --recursive`, or nothing
resolves.
- **JDK 17** — not newer; the Android Gradle Plugin requires exactly 17. Set
`JAVA_HOME` if your default differs.
- **Android SDK** — platform 37 (`compileSdk`) and build-tools 36.0.0, located
via `ANDROID_HOME` or a gitignored `local.properties` with `sdk.dir`. If you
go the `local.properties` route the included build needs its own copy at
`floret-kit/local.properties`; `ANDROID_HOME` covers both at once and is the
easier path.
The Gradle wrapper is checked in, so no system Gradle is needed.
```sh
./gradlew lint test assembleDebug # roughly what CI runs
```
A single test class, or a pattern:
```sh
./gradlew testDebugUnitTest --tests "de.jeanlucmakiola.calendula.domain.SimpleRecurrenceTest"
./gradlew testDebugUnitTest --tests "*SimpleRecurrence*"
```
CI reports one `CI` check per pull request: `lintDebug`, `testDebugUnitTest`,
`assembleDebug`, and a Trivy scan. Pull requests touching only docs, F-Droid
metadata or the licence skip the Android build and go green quickly. More detail
in [`docs/BUILDING.md`](docs/BUILDING.md).
## The rules
These are the ones that turn into review comments.
1. **No network.** Calendula holds no `INTERNET` permission, and that's a
feature rather than an oversight. Anything that would need one is a product
decision before it's a patch — the crash reporter deliberately opens a
prefilled web issue instead of posting anything itself.
2. **The provider is the only database.** No Room, no cache, no local mirror of
events. `CalendarContract` is the single source of truth, which is also why
externally synced changes work for free.
3. **Don't patch UI state after a write.** A `ContentObserver` re-queries and
views recompose from fresh provider state. Hand-patching a list after saving
appears to work, then quietly diverges from what the provider actually stored.
4. **`domain/` has no Android imports.** Models, validation, recurrence
rendering, conflict snapshots and the `.ics` codec stay pure Kotlin so they
remain JVM-testable.
5. **Tests run on the JVM.** JUnit 5 + Truth + Turbine. The seams exist for you:
fake the data source (`FakeCalendarDataSource`), and feed mappers plain maps
through `ColumnReader` instead of cursors. Instrumented tests are a last
resort, not a default.
6. **Read before touching the subtle pipelines.** Recurring writes (UNTIL vs
DURATION, exception URIs, series splits), save-conflict detection and reminder
delivery (post-before-mark) follow provider-driven rules that are documented
in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) and are not guessable from
the code alone.
7. **Don't break reproducible builds.** `vcsInfo`, `dependenciesInfo` and the AGP
metadata block are disabled on purpose so the official F-Droid repo can verify
our binary against a from-source rebuild.
`scripts/check_reproducible_release.sh` runs on every pull request, including
docs-only ones.
## UI conventions
Material 3 Expressive throughout, built from the system's own tokens and
components — colour-scheme tokens rather than hardcoded colours, `ListItem` for
settings rows.
**Selection pickers are full-screen.** Every browse-style "choose one" surface
uses floret-kit's `FullScreenPicker` / `OptionPicker`; one that needs a commit or
extra action passes it through the picker's `actions` slot. The exception is the
recurring-scope chooser (*this / this and following / all*), which stays a
compact dialog — a two- or three-option decision reads better as a popup than as
a nearly empty screen. `AlertDialog` is for plain confirmations only, and radio-
or text-list dialogs aren't used at all.
Shared UI machinery lives in the `floret-kit` submodule and has
[its own contributing guide](https://codeberg.org/jlmakiola/floret-kit/src/branch/main/CONTRIBUTING.md);
changing it means a pull request against that repository plus a submodule bump
here.
## Commits & pull requests
Conventional commits, scoped to the area you touched:
```
fix(calendars): keep an event's own calendar when it is switched off
feat(month): pull-to-expand the split view (#38)
docs(architecture): record what the second review pass changed
```
Types in use: `feat` `fix` `docs` `refactor` `style` `chore` `ci` `build`
`revert`. Reference the issue in the subject or the body. Keep commits small —
small commits revert cleanly, which matters more here than a tidy history.
If your change is user-visible, add an entry under `## [Unreleased]` in
[`CHANGELOG.md`](CHANGELOG.md). Match the surrounding voice: entries describe
what changed *for the person using the app*, and why, not what changed in the
code. Link the issue and add its reference at the bottom of the file. It may get
reworded when the release is cut, so don't agonise over it.
Please don't commit planning or design documents. Code, tests, architecture notes
and the changelog land; the reasoning belongs in the commit message and the
issue.
## Licence
Calendula is [MIT](LICENSE). By contributing you agree your changes ship under
the same licence.

10
Gemfile Normal file
View File

@@ -0,0 +1,10 @@
source "https://rubygems.org"
# fastlane is used ONLY to upload the release bundle to Google Play
# (see fastlane/Fastfile). It is not part of the build or the signing path, so
# it never runs on a PR — only in release.yaml's `play` job.
#
# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is
# committed on purpose: this resolves an uploader's transitive deps, not the
# app's, and none of it affects the reproducible release build.
gem "fastlane", "2.237.0"

101
README.md
View File

@@ -18,15 +18,18 @@ 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>
&nbsp;
<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>
&nbsp;
<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">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="19%" alt="Month view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-edit.png" width="19%" alt="Event form">&nbsp;
<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="16%" alt="Week view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="16%" alt="Month view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-day.png" width="16%" alt="Day view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="16%" alt="Event detail">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-agenda.png" width="16%" alt="Agenda view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="16%" alt="Reminder onboarding">
</p>
</div>
@@ -68,24 +71,38 @@ database, no sync stack reinvented.
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
expressive motion and shapes, light/dark theme
- German and English UI, per-app language setting — and [open to community
translations](#-translations)
- English and German UI plus community translations (Spanish, French, Italian,
Polish, and more in progress), per-app language setting — and [open to more
languages](#-translations)
- **Zero telemetry, zero analytics, no internet permission** — your data
never leaves the device
## 📦 Install
Pick whichever channel you already use — they all install the same app:
| Channel | Updates | Notes |
| --- | --- | --- |
| [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup |
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; needs the repo added once |
| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automated by Obtainium |
| [Google Play](#google-play-coming-soon) | — | Coming soon |
| [Build from source](#build-from-source) | Whenever you build | Full control |
### F-Droid (recommended)
Calendula is on the **official [F-Droid](https://f-droid.org) repository**
just search for **Calendula** in any F-Droid client, or
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
### Self-hosted repo (latest builds)
F-Droid rebuilds from source on its own schedule, so a new version usually
shows up there a few days after release.
New versions are built, signed, and published to a self-hosted repository the
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)
Every release is built, signed, and published to a self-hosted F-Droid
repository as part of the release pipeline, so it lands there first. Add it once
and your F-Droid client handles updates from then on:
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
link below on your phone):
@@ -100,19 +117,71 @@ rebuilds on F-Droid's own schedule. Add it for the freshest builds:
2. Refresh, search for **Calendula**, install.
Both channels share the same signing key, so you can switch between them
without reinstalling. Or build from source — see below.
### Codeberg release / Obtainium
If you'd rather not use F-Droid at all, every release is also published on
**[Codeberg](https://codeberg.org/jlmakiola/calendula/releases)** with the
signed APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum attached — download
and install it directly.
For automatic updates from that channel, use
**[Obtainium](https://github.com/ImranR98/Obtainium)** — on the phone,
**[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula)**,
or do it by hand: *Add App* → paste `https://codeberg.org/jlmakiola/calendula`
→ *Add*. Either way, Obtainium tracks the releases and prompts you when a new
one appears.
### Google Play (coming soon)
Calendula is on its way to Google Play as an additional channel. It isn't live
yet — this section gets a link once it is. Play builds will be signed with
Google's key rather than mine, so switching between Play and any other channel
will require an uninstall.
> **Testers wanted.** Play requires a round of closed testing before the app can
> go public, and I'm still looking for testers. If you'd like to help, email
> **[business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de)** with the
> Google account address you want to use — that address is what I need to add you
> to the closed test.
### Build from source
The build is a plain Gradle build with no proprietary dependencies — see
**[docs/BUILDING.md](docs/BUILDING.md)** (note the `floret-kit` submodule).
<sub>Official F-Droid, the self-hosted repo, and the Codeberg releases all share
the same signing key, so you can switch freely between them without
reinstalling.</sub>
## 📚 Documentation
- **[Contributing](CONTRIBUTING.md)** — how to report, propose, and patch
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next
- **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next
## 🤝 Contributing
Bug reports, ideas, and patches are all welcome on
**[Codeberg](https://codeberg.org/jlmakiola/calendula/issues)**.
The short version: **start with an issue.** Features get a yes-or-no before they
get code, and both features and bugs are assigned a milestone whose
`release/vX.Y.Z` branch your pull request then targets. Typo and docs fixes can
skip straight to a pull request. Translations don't go through pull requests at
all — [Weblate owns them](#-translations).
Read **[CONTRIBUTING.md](CONTRIBUTING.md)** before writing code: it covers the
workflow, the build (note the `floret-kit` submodule), and the architectural
rules a change is reviewed against.
## 🌍 Translations
Calendula ships in German and English, and you're warmly invited to add your
language. Translations are managed on a self-hosted **Weblate**:
Calendula ships in English and German, with community translations in Arabic,
Chinese, French, Italian, Polish, Portuguese, Russian, and Spanish at varying
degrees of completeness — partial is fine, untranslated strings simply fall back
to English. You're warmly invited to add or finish your language. Translations
are managed on a self-hosted **Weblate**:
**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**

View File

@@ -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 = 21600
versionName = "2.16.0"
versionCode = 21903
versionName = "2.19.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -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

View File

@@ -47,12 +47,9 @@ class CalendulaApp : Application() {
}
/**
* Bring reminder delivery up with the process (#75). The app plans and arms
* its own reminder alarms now, so launch is one of the moments that has to
* re-check them: a scan re-arms whatever the system dropped, posts anything
* a missed alarm still owes, and starts watching the provider so an edit
* re-plans without waiting for the next pass. The daily worker is the
* backstop for a device that drops the alarm with no reboot to announce it.
* 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(
@@ -65,12 +62,9 @@ class CalendulaApp : Application() {
}
/**
* Flush any calendar switch-off the app hasn't been allowed to write into
* the system's `Calendars.VISIBLE` yet — including the retired app-local
* "disabled calendars" set the upgrade inherits (#75). A no-op on a fresh
* install and in the steady state; a launch without the calendar permission
* leaves the set pending, and `RootScreen` runs it again once the app comes
* up holding it — whichever way it was granted.
* 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(
@@ -101,10 +95,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)

View File

@@ -2,9 +2,11 @@ package de.jeanlucmakiola.calendula
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.provider.CalendarContract
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
@@ -12,6 +14,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -29,11 +32,14 @@ import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
import de.jeanlucmakiola.floret.components.DebugRibbon
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.domain.FontRole
@@ -50,9 +56,17 @@ 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.
@@ -69,7 +83,7 @@ class MainActivity : AppCompatActivity() {
// A prefilled new-event form from an external ACTION_INSERT launch (another
// app/widget asking us to create an event, issue #30). Consumed once by
// CalendarHost, which opens it in the create form for review.
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
private var requestedInsert by mutableStateOf<InsertRequest?>(null)
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
// opens the occurrence in the edit form. Same occurrence-key shape as the
@@ -96,11 +110,13 @@ class MainActivity : AppCompatActivity() {
return
}
enableEdgeToEdge()
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES
applyEdgeToEdge()
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requestedNav = intent.navRequestOrNull()
requestedImportUri = intent.importUriOrNull()
requestedInsertForm = intent.insertFormOrNull()
requestedInsert = intent.insertRequestOrNull()
requestedEditKey = intent.editEventKeyOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent {
@@ -113,6 +129,14 @@ class MainActivity : AppCompatActivity() {
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
// onCreate can only see the night resource qualifier, not the in-app
// override — re-apply from the resolved theme so the bar icons follow
// the app's light/dark choice.
DisposableEffect(darkTheme) {
systemBarsDark = darkTheme
applyEdgeToEdge()
onDispose {}
}
// The app-wide clock convention: the time-format preference resolved
// against the device's 24-hour system setting, provided once here so
// every time label reads it via LocalUse24HourFormat.
@@ -125,6 +149,13 @@ class MainActivity : AppCompatActivity() {
// re-import stamp AppFontSettings carries, so replacing the file
// behind an active "custom" token still refreshes — changes;
// "system for both" returns the default scale untouched.
// The timeline scale plus the pinch in flight over it (#56). Held
// here, above the calendar views, so a zoom survives paging between
// weeks and switching between the week and day view.
val timelineZoom = rememberTimelineZoom(
stored = settings.timelineScale,
onPersist = settingsViewModel::setTimelineScale,
)
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
val typography = remember(fonts, context) {
calendulaTypography(
@@ -141,6 +172,7 @@ class MainActivity : AppCompatActivity() {
CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour,
LocalShowHourLines provides settings.showHourLines,
LocalTimelineZoom provides timelineZoom,
LocalSoftenColors provides settings.softenColors,
) {
RootScreen(
@@ -151,8 +183,9 @@ class MainActivity : AppCompatActivity() {
onWidgetNavConsumed = { requestedNav = null },
requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null },
requestedInsertForm = requestedInsertForm,
onInsertConsumed = { requestedInsertForm = null },
requestedInsertForm = requestedInsert?.form,
requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
onInsertConsumed = { requestedInsert = null },
requestedEditKey = requestedEditKey,
onEditKeyConsumed = { requestedEditKey = null },
)
@@ -181,6 +214,19 @@ class MainActivity : AppCompatActivity() {
}
}
/**
* Applies the transparent edge-to-edge bars for the current [systemBarsDark].
* Both scrims are transparent because API 29+ enforces its own contrast and
* ignores them anyway.
*/
private fun applyEdgeToEdge() {
val transparent = android.graphics.Color.TRANSPARENT
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(transparent, transparent) { systemBarsDark },
navigationBarStyle = SystemBarStyle.auto(transparent, transparent) { systemBarsDark },
)
}
override fun onResume() {
super.onResume()
// Reaching a running UI means startup succeeded; reset the loop trail.
@@ -192,7 +238,7 @@ class MainActivity : AppCompatActivity() {
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it }
intent.importUriOrNull()?.let { requestedImportUri = it }
intent.insertFormOrNull()?.let { requestedInsertForm = it }
intent.insertRequestOrNull()?.let { requestedInsert = it }
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
}
@@ -219,9 +265,10 @@ class MainActivity : AppCompatActivity() {
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
* event", i.e. create). The new event's fields ride as CalendarContract
* extras; anything omitted falls back to the in-app "new event" defaults in
* [buildInsertEventForm].
* [buildInsertEventForm]. An intent that names no end time leaves the length
* to the default-duration setting ([ImportSource.InsertOpenEnded], #54).
*/
private fun Intent.insertFormOrNull(): EventForm? {
private fun Intent.insertRequestOrNull(): InsertRequest? {
// ACTION_EDIT / ACTION_INSERT_OR_EDIT on an existing event route to the
// edit form instead ([editEventKeyOrNull]); an id-less one is a create,
// as is any plain ACTION_INSERT.
@@ -229,9 +276,11 @@ class MainActivity : AppCompatActivity() {
((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) &&
editEventKeyOrNull() == null)
if (!isCreate) return null
return buildInsertEventForm(
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
val beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME)
val endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME)
val form = buildInsertEventForm(
beginMillis = beginMillis,
endMillis = endMillis,
isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
title = getStringExtra(CalendarContract.Events.TITLE),
description = getStringExtra(CalendarContract.Events.DESCRIPTION),
@@ -240,6 +289,10 @@ class MainActivity : AppCompatActivity() {
zone = TimeZone.currentSystemDefault(),
now = Clock.System.now(),
)
// An end the intent didn't name — or one [buildInsertEventForm] drops for
// landing before the start — leaves the length to the setting.
val namesEnd = beginMillis != null && endMillis != null && endMillis >= beginMillis
return InsertRequest(form, if (namesEnd) ImportSource.Insert else ImportSource.InsertOpenEnded)
}
/** A Long extra's value, or null when the extra is absent. */

View File

@@ -73,9 +73,8 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
/**
* Recover the semantic whole-day lead time from a raw all-day reminder
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
* detail screen. Delegates to [allDayLeadDays] so what is displayed is the day
* the reminder actually fires on; see there for how an encoded row is told from
* a plain one written by another calendar app.
* detail screen. Delegates to [allDayLeadDays], so the day displayed is the day
* the reminder actually fires on.
*/
internal fun fromProviderAllDayMinutes(
rawMinutes: Int,

View File

@@ -27,8 +27,10 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.curatedForPicker
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventSearch
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.SearchCandidate
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
@@ -62,12 +64,14 @@ interface CalendarDataSource {
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
/**
* Master/one-off events whose title, description or location contains
* [query] (case-insensitive), across all calendars, newest first. Reads the
* Events table directly so the search is unbounded in time; exception rows
* are excluded (see [SearchProjection]). [query] is assumed non-blank.
* Master/one-off events that may match [query], across all calendars: every
* whitespace-separated token has to appear in the title, description or
* location. Reads the Events table directly so the search is unbounded in
* time; exception rows are excluded (see [SearchProjection]). A deliberate
* superset — [EventSearch] makes the final call and orders the hits.
* [query] is assumed non-blank.
*/
fun searchEvents(query: String): List<EventInstance>
fun searchEvents(query: String): List<SearchCandidate>
/**
* The event-colour palette the calendar's account publishes
@@ -117,19 +121,15 @@ interface CalendarDataSource {
/**
* Show or hide the calendar device-wide by writing `Calendars.VISIBLE` — the
* app's one visibility model (#75). `VISIBLE` also gates the provider's own
* reminder scheduling, so switching a calendar off here is what actually
* stops its notifications; switching it on is what brings them back.
* Writable by a plain app (one of the three columns the platform documents
* as such) and device-local — no sync adapter pushes it anywhere.
* app's one visibility model (#75), which also gates its reminders. One of
* the three columns the platform documents as app-writable, and device-local.
*/
fun setCalendarVisible(id: Long, visible: Boolean)
/**
* Whether one calendar is currently switched on at system level, without
* reading every row — for the reminder gate, which sees a calendar id and
* nothing else. Null when the answer can't be had: no row (the calendar was
* deleted) or no read permission.
* Whether one calendar is switched on at system level, without reading every
* row — for the reminder gate, which sees only a calendar id. Null when the
* answer can't be had: no row, or no read permission.
*/
fun isCalendarVisible(id: Long): Boolean?
@@ -405,14 +405,11 @@ class AndroidCalendarDataSource @Inject constructor(
}
/**
* Addressed by appended id on the plain (non-sync-adapter) Calendars URI,
* one calendar per call. Both parts are load-bearing:
* `CalendarProvider2.updateInTransaction` short-circuits to a raw database
* update unless the selection is `_id=…`, skipping the dirty marking *and*
* the `checkNextAlarm()` reschedule — i.e. an `_id IN (…)` batch would write
* the flag but never re-arm the reminder alarms this write exists to
* trigger. The sync-adapter URI is avoided so the write also applies to
* synced calendars, which is where the bug bites.
* Addressed by appended id on the plain (non-sync-adapter) Calendars URI, one
* calendar per call. Both parts are load-bearing:
* `CalendarProvider2.updateInTransaction` skips the dirty marking and the
* `checkNextAlarm()` reschedule unless the selection is `_id=…`, and the
* plain URI is what makes the write apply to synced calendars too.
*/
override fun setCalendarVisible(id: Long, visible: Boolean) {
val values = ContentValues().apply {
@@ -592,20 +589,23 @@ class AndroidCalendarDataSource @Inject constructor(
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
}
override fun searchEvents(query: String): List<EventInstance> {
override fun searchEvents(query: String): List<SearchCandidate> {
ensureObserversRegistered()
val trimmed = query.trim()
if (trimmed.isEmpty()) return emptyList()
// Escape the SQL LIKE wildcards so a literal % or _ in the query matches
// itself instead of acting as a wildcard.
val escaped = trimmed
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
val like = "%$escaped%"
val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
val tokens = EventSearch.tokenize(query).take(MAX_SEARCH_TOKENS)
if (tokens.isEmpty()) return emptyList()
// Only a pre-filter: EventSearch re-checks every token, including the
// ones the cap above dropped.
val patterns = tokens.map { likePatterns(it) }
val match = patterns.joinToString(" AND ") { variants ->
variants.joinToString(" OR ", prefix = "(", postfix = ")") {
"${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
}
}
val args = patterns
.flatMap { variants -> variants.flatMap { pattern -> List(3) { pattern } } }
.toTypedArray()
val selection = "($match) AND " +
"${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
@@ -613,19 +613,17 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Events.CONTENT_URI,
SearchProjection.COLUMNS,
selection,
arrayOf(like, like, like),
args,
CalendarContract.Events.DTSTART + " DESC",
)?.use { c ->
val reader = CursorColumnReader(c)
val out = ArrayList<EventInstance>(c.count)
val out = ArrayList<SearchCandidate>(c.count)
while (c.moveToNext()) {
val description = reader.getString(SearchProjection.IDX_DESCRIPTION)
val base = reader.toSearchResult() ?: continue
// A recurring master's DTSTART is the series start; show its
// nearest occurrence instead so the date is the one the user
// actually cares about (and sorting reflects it).
val recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
!reader.getString(SearchProjection.IDX_RDATE).isNullOrEmpty()
out += if (recurring) {
// A recurring master's DTSTART is the series start; date the row
// by its nearest occurrence instead.
val event = if (base.isRecurring) {
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
base.copy(
start = begin.toKotlinInstantFromEpochMillis(),
@@ -635,11 +633,44 @@ class AndroidCalendarDataSource @Inject constructor(
} else {
base
}
// The raw title, so matching can't catch on the placeholder an
// untitled event is drawn with.
out += SearchCandidate(
event = event,
description = description,
title = reader.getString(SearchProjection.IDX_TITLE),
)
}
out
} ?: emptyList()
}
/**
* One token as the `LIKE` patterns to OR together. SQLite folds case for
* ASCII only, so a token with cased non-ASCII letters is also queried lower-,
* upper- and title-cased, which is how "ärzte" reaches "Ärzte". Wildcarding
* those letters away instead would make "москва" `%______%` — a full scan.
*/
private fun likePatterns(token: String): List<String> {
if (token.none { it.code > 127 && (it.isUpperCase() || it.isLowerCase()) }) {
return listOf(likePattern(token))
}
val lower = token.lowercase()
return listOf(token, lower, token.uppercase(), lower.replaceFirstChar(Char::uppercaseChar))
.distinct()
.map(::likePattern)
}
private fun likePattern(token: String): String {
val sb = StringBuilder("%")
for (c in token) {
// A literal wildcard from the query matches itself.
if (c == '%' || c == '_' || c == '\\') sb.append('\\')
sb.append(c)
}
return sb.append('%').toString()
}
/**
* The occurrence of [eventId] nearest to now: the soonest upcoming one
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
@@ -1600,5 +1631,12 @@ class AndroidCalendarDataSource @Inject constructor(
* next fires beyond it falls back to its series-start date.
*/
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
/**
* Tokens the SQL pre-filter is built from. Bounds the statement for a
* pasted paragraph; the ranker still requires every token, so a capped
* search returns fewer rows to check, never more hits.
*/
const val MAX_SEARCH_TOKENS = 8
}
}

View File

@@ -31,9 +31,7 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
isManaged = isLocal &&
getString(CalendarProjection.IDX_MANAGED_MARKER)
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
// A provider that leaves the column NULL is treated as syncing — the
// harmless default, since this flag only ever holds the one-shot
// visibility migration back from switching a calendar on.
// NULL is treated as syncing — the harmless default.
syncsEvents = isNull(CalendarProjection.IDX_SYNC_EVENTS) ||
getInt(CalendarProjection.IDX_SYNC_EVENTS) != 0,
)

View File

@@ -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
@@ -39,16 +40,12 @@ interface CalendarRepository {
suspend fun deleteCalendar(id: Long)
/**
* Show or hide [ids] device-wide (`Calendars.VISIBLE`), which is also what
* turns the provider's reminder scheduling for them on or off — see
* [CalendarDataSource.setCalendarVisible]. Each calendar is written on its
* own, in order; a failure part-way leaves the earlier writes standing (the
* observer reports whatever actually landed).
* 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]),
* where it filters events and reminders just the same until it can be
* written.
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds]).
*/
suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean)

View File

@@ -9,6 +9,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
@@ -68,23 +69,17 @@ class CalendarRepositoryImpl @Inject constructor(
/**
* Re-query signal for everything filtered by visibility: the provider's own
* notifications, plus every change to the pending switch-off set (an id
* leaves it as its `VISIBLE` write lands, which changes what is shown).
* [calendarsSnapshot] keeps the two in step.
* notifications, plus every change to the pending switch-off set.
*/
private fun visibilityTicks(): Flow<Unit> = merge(
ticks.onStart { emit(Unit) },
// The current value is already covered by the tick above; only later
// changes re-query (the set is deduped, so an unrelated DataStore write
// doesn't).
// drop(1): the current value is already covered by the tick above.
prefs.pendingDisabledCalendarIds.drop(1).map {},
)
// A switch-off the app hasn't been allowed to write yet is folded into the
// flag itself, so every consumer — the Settings switch, the filter sheet,
// the form and import pickers, the widgets — reads one visibility and can't
// disagree with what the user just tapped. The reconciler reads the data
// source directly, because it needs the provider's own answer.
// 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()
@@ -94,19 +89,13 @@ class CalendarRepositoryImpl @Inject constructor(
if (it.id in pendingDisabled) it.copy(isVisibleInSystem = false) else it
}
}
// Collapse re-emissions that carry an identical list (see
// [instances]).
.distinctUntilChanged()
.flowOn(io)
// Instances are filtered by the system's per-calendar VISIBLE flag the
// switch-offs still waiting to be written to it the app-side hidden set:
// an event is dropped when the user switched its calendar off in Settings →
// Calendars (which also stops the provider scheduling its reminders) *or*
// hid it in the filter sheet. Re-runs when the provider ticks — writing
// VISIBLE notifies, so switching a calendar updates every view — or when
// either set changes. [calendars] stays unfiltered so those screens can list
// and re-enable invisible calendars.
// 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 {
@@ -127,10 +116,8 @@ class CalendarRepositoryImpl @Inject constructor(
if (excluded.isEmpty()) queried.instances
else queried.instances.filterNot { it.calendarId in excluded }
}
// Any DataStore edit re-emits the hidden set even when it is
// unchanged (e.g. writing the last-used calendar), which would
// re-surface an identical list — collapse those so views don't
// re-render for them.
// Any DataStore edit re-emits the hidden set even when unchanged;
// collapse those so views don't re-render for them.
.distinctUntilChanged()
.flowOn(io)
@@ -151,21 +138,14 @@ class CalendarRepositoryImpl @Inject constructor(
private var cachedCalendars: List<CalendarSource> = emptyList()
/**
* The calendar list for the current tick, queried once and shared. Every
* open view collects [calendars] *and* filters its instances by visibility,
* which used to cost one full `Calendars` query each per tick. Reusing a
* single read also keeps them consistent: within a tick, what a screen lists
* and what its events are filtered against can't come from two snapshots.
* 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 — it is what a read without the calendar
* permission returns, and the grant itself doesn't notify the provider.
*
* The pending switch-off set keys the cache alongside the tick. An id leaves
* that set the moment its `VISIBLE` write lands, while the observer that
* would invalidate the snapshot is only dispatched through the main looper
* afterwards — so a snapshot taken while the id was still pending, read
* against the set that no longer holds it, would report the calendar as *on*
* again and re-admit exactly the events being hidden.
* 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()
@@ -183,13 +163,13 @@ class CalendarRepositoryImpl @Inject constructor(
?: throw NoSuchEventException(eventId)
}
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) {
if (query.isBlank()) return@withContext emptyList()
val excluded = prefs.hiddenCalendarIds.first() +
prefs.pendingDisabledCalendarIds.first() +
invisibleCalendarIds()
dataSource.searchEvents(query)
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
.let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } }
}
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =

View File

@@ -21,25 +21,15 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Keeps the app's pending "switched off" set (see
* [CalendarPrefs.pendingDisabledCalendarIds]) and the system's
* `Calendars.VISIBLE` in step — the fold-in of the retired app-local visibility
* model (#75), and the standing drain for switch-offs made without
* 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 again whenever the app comes up holding the calendar
* permission — a grant made on Android's own app-settings screen never reaches
* the permission screen's callback. It is a no-op whenever the pending set is
* empty and the notice has been settled, which
* is the steady state: each entry is written and dropped individually, so a run
* that dies part-way resumes exactly where it stopped and never re-applies a
* write the user has since undone by hand.
*
* The reconciliation only hides (see [calendarVisibilityPlan]). Calendars hidden
* at system level stay hidden, and on an *upgraded* install the first run that
* sees one arms the one-time notice explaining why Calendula no longer lists
* their events. A fresh install never had the old behaviour, so it retires that
* notice unshown — every other device ships with something hidden.
* 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(
@@ -50,43 +40,30 @@ class CalendarVisibilityReconciler @Inject constructor(
) {
suspend fun run() = withContext(io) {
// Everything, the DataStore reads included, sits inside the guard: this
// runs in a bare application-scope coroutine with no exception handler,
// so an IOException from a damaged preferences file would otherwise take
// the process down on every launch.
// 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 {
// A fresh install has no retired model behind it — nothing to
// migrate, and nothing to explain. Settled ahead of the permission
// gate so an update installed before the first grant can't make a
// first run look like an upgrade afterwards.
// 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
// The steady state, and every run after the first: nothing left to
// drain and nothing left to decide, so don't pay for the query.
if (pending.isEmpty() && noticeSettled) return@withContext
val calendars = dataSource.calendars()
// An empty read means "couldn't read", not "no calendars": the data
// source turns a null cursor — a provider momentarily unavailable —
// into an empty list. Both decisions below are one-way, so taking
// that reading as the truth would drop the whole pending set without
// ever writing VISIBLE = 0 (switching the user's calendars back on,
// events and reminders with them) and settle the notice as "nothing
// to explain". Leave both to the next run.
// 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)
// Already off, or gone from the device — nothing to write, so let
// those ids leave the pending set with the rest.
prefs.removePendingDisabledCalendarIds(plan.settled)
// One calendar per write: the provider skips its reminder-alarm
// reschedule for anything but a single-id update (see
// [CalendarDataSource.setCalendarVisible]). Dropping each id as it
// lands keeps a part-applied run resumable.
// [CalendarDataSource.setCalendarVisible]).
for (id in plan.hide) {
dataSource.setCalendarVisible(id, false)
prefs.removePendingDisabledCalendarIds(setOf(id))
@@ -100,9 +77,7 @@ class CalendarVisibilityReconciler @Inject constructor(
/**
* Settle the one-time notice: [pending] arms it, false retires it unshown.
* Answered once, by whichever run can answer it first; the answer is stored
* either way, so the notice can't resurface later, when the same state would
* no longer be news to the user.
* Answered once and stored either way, so it can't resurface later.
*/
private suspend fun settleNoticeOnce(pending: Boolean) {
if (prefs.visibilityNoticePending.first() != null) return
@@ -111,10 +86,7 @@ class CalendarVisibilityReconciler @Inject constructor(
/**
* Whether this install has ever run an earlier version. The notice explains
* a change to behaviour the user has already seen, so a first install has
* nothing to announce — and hidden calendars are the *norm* on a fresh
* device (a second account's, "Holidays in …", a subscribed calendar), which
* would otherwise put a changelog dialog in front of a first-run user.
* a behaviour change, so a first install has nothing to announce.
*/
private fun isUpgradeInstall(): Boolean = try {
@Suppress("DEPRECATION")

View File

@@ -125,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),
)
}

View File

@@ -1,6 +1,7 @@
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
@@ -38,5 +39,7 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
color = color,
location = getString(InstanceProjection.IDX_LOCATION),
isDeclined = getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS) ==
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
)
}

View File

@@ -52,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
@@ -64,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 {
@@ -89,6 +91,9 @@ internal object EventDetailProjection {
// Recurring rows carry DURATION instead of DTEND; the detail screen
// needs it to render a series opened without a named occurrence.
CalendarContract.Events.DURATION,
// Non-null on a modified-occurrence exception row; "no RRULE" alone
// can't tell an exception from a master (#68).
CalendarContract.Events.ORIGINAL_ID,
)
const val IDX_EVENT_ID = 0
@@ -110,6 +115,7 @@ internal object EventDetailProjection {
const val IDX_SELF_ATTENDEE_STATUS = 16
const val IDX_EVENT_COLOR_KEY = 17
const val IDX_DURATION = 18
const val IDX_ORIGINAL_ID = 19
}
/**
@@ -176,6 +182,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
@@ -190,6 +199,8 @@ internal object SearchProjection {
const val IDX_LOCATION = 9
const val IDX_RRULE = 10
const val IDX_RDATE = 11
const val IDX_DESCRIPTION = 12
const val IDX_SELF_ATTENDEE_STATUS = 13
}
/**

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
@@ -40,5 +41,9 @@ 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(),
isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) ==
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
)
}

View File

@@ -29,10 +29,8 @@ class CalendarPrefs @Inject constructor(
private val store: DataStore<Preferences>,
) {
// Both id sets are deduped: the store is shared with SettingsPrefs, so every
// unrelated write (a settings toggle, the last-used calendar) re-emits an
// identical set otherwise — and a change to the pending set now costs a
// fresh provider read in CalendarRepositoryImpl.
// 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()
@@ -42,17 +40,13 @@ class CalendarPrefs @Inject constructor(
}
/**
* Calendars switched off in Settings → Calendars that the provider does not
* know about yet. That switch writes the system's `Calendars.VISIBLE` (#75),
* which needs `WRITE_CALENDAR` — a user who granted read-only keeps their
* choice here instead, and so does everyone upgrading from the retired
* app-local model, whose set is read straight back out of the same key.
* 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 for as long as it is non-empty,
* so an un-flushable switch still does what the user asked. Not a second
* visibility model: `CalendarVisibilityReconciler` drains it into the
* provider entry by entry the moment the app may write, and nothing ever
* adds to it while it may.
* 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() }
@@ -62,9 +56,8 @@ class CalendarPrefs @Inject constructor(
editPendingDisabled { it + ids }
/**
* Drop [ids] from the pending set one id at a time as the reconciler
* flushes it, so a run that fails part-way never re-applies what already
* landed (and can't undo a switch the user has since flipped by hand).
* 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() }

View File

@@ -10,19 +10,13 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* How far reminder delivery has got. One number, and it replaces everything the
* provider's `CalendarAlerts.STATE` used to do for us (#75).
* 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.
*
* A scan posts the reminders whose moment falls after this watermark and up to
* now, then moves it to now. That single rule gives both halves of what the
* retired path got from the provider: a scan that runs twice cannot post the
* same reminder again, and a scan that runs *late* — after a reboot, an app
* update or a doze window swallowed the alarm — still posts everything the
* missed alarm would have.
*
* Unset means "never scanned". It is deliberately not treated as zero: the first
* scan after an install or an upgrade would otherwise consider every reminder
* since the epoch overdue and bury the user in notifications.
* 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(

View File

@@ -21,6 +21,9 @@ 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.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
@@ -270,6 +273,19 @@ class SettingsPrefs @Inject constructor(
store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name }
}
/**
* How tall an hour is drawn in the week and day timelines (#56). Defaults to
* [TimelineScale.Regular] — the historical 56dp scale. Holds either a preset
* or the height a pinch on the timeline settled at.
*/
val timelineScale: Flow<TimelineScale> = store.data.map { prefs ->
parseTimelineScale(prefs[TIMELINE_SCALE_KEY])
}
suspend fun setTimelineScale(scale: TimelineScale) {
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
}
/**
* 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 "+"
@@ -285,6 +301,19 @@ class SettingsPrefs @Inject constructor(
store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled }
}
/**
* Whether events can be dragged to another slot in the calendar views (#68,
* #173). Defaults to ON. Off means no block registers a drag gesture at all;
* rescheduling then goes through the edit form.
*/
val dragToReschedule: Flow<Boolean> = store.data.map { prefs ->
prefs[DRAG_TO_RESCHEDULE_KEY] ?: true
}
suspend fun setDragToReschedule(enabled: Boolean) {
store.edit { it[DRAG_TO_RESCHEDULE_KEY] = enabled }
}
/**
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
* [AgendaRange.Month] — a month of upcoming events. Independent of the
@@ -457,6 +486,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
@@ -494,8 +563,87 @@ 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 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
}
}
/**
@@ -808,14 +956,33 @@ class SettingsPrefs @Inject constructor(
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style")
internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale")
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
internal val DRAG_TO_RESCHEDULE_KEY = booleanPreferencesKey("drag_to_reschedule")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
internal val DEFAULT_EVENT_DURATION_KEY = intPreferencesKey("default_event_duration_minutes")
internal val CALENDAR_EVENT_DURATION_KEY =
stringPreferencesKey("per_calendar_event_duration")
/** A new timed event's length until the user changes it: one hour. */
const val DEFAULT_EVENT_DURATION = 60
internal const val MIN_EVENT_DURATION = 1
/** A day — past that the form is describing a multi-day event, not a default. */
internal const val MAX_EVENT_DURATION = 1_440
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
internal val ONBOARDING_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")
@@ -905,6 +1072,17 @@ fun resolveDefaultReminder(
}
}
/**
* The length a new timed event on [calendarId] opens with: that calendar's
* override if it has one, otherwise the [global] default. Pure so it can be
* unit-tested.
*/
fun resolveDefaultEventDuration(
global: Int,
overrides: Map<Long, Int>,
calendarId: Long?,
): Int = calendarId?.let { overrides[it] } ?: global
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
private const val WEEK_START_AUTO = "AUTO"
@@ -952,6 +1130,27 @@ private fun String?.toReminderList(): List<Int> = when {
private fun List<Int>.toStoredReminders(): String =
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
/**
* Parse the per-calendar duration map (`id=minutes` entries joined by `;`).
* Malformed entries and out-of-range lengths are dropped, so a garbled value
* degrades to "inherits the global default" instead of throwing.
*/
private fun parseDurationOverrides(stored: String?): Map<Long, Int> =
stored?.split(ENTRY_SEP).orEmpty().mapNotNull { entry ->
val parts = entry.split(KEY_VALUE_SEP)
if (parts.size != 2) return@mapNotNull null
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
val minutes = parts[1].trim().toIntOrNull()
?.takeIf { it in SettingsPrefs.MIN_EVENT_DURATION..SettingsPrefs.MAX_EVENT_DURATION }
?: return@mapNotNull null
calendarId to minutes
}.toMap()
private fun Map<Long, Int>.toStoredDurations(): String =
entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, minutes) ->
"$id$KEY_VALUE_SEP$minutes"
}
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default

View File

@@ -93,13 +93,9 @@ class ReminderActionReceiver : BroadcastReceiver() {
private const val EXTRA_ALL_DAY = "all_day"
/**
* An explicit intent to this receiver carrying [alert] as extras.
*
* The data URI names the reminder and carries no information the extras
* don't. It is what actually keeps two reminders' `PendingIntent`s
* apart: `filterEquals` compares action, component, data, type and
* categories — never extras — so without it a request-code collision
* would have them share one alarm and one payload.
* 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 {
@@ -116,13 +112,9 @@ class ReminderActionReceiver : BroadcastReceiver() {
}
/**
* A stable request code per (alert, action) so one notification's
* PendingIntents stay distinct and don't clobber each other.
*
* The key is a hash now rather than a small row id, so the shift is what
* keeps the action slot intact; the top three bits it drops can make two
* *different* reminders share a code, which the per-reminder data URI in
* [intent] separates.
* 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) {

View File

@@ -19,17 +19,10 @@ internal fun AlarmManager.canScheduleExactCompat(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
/**
* Holds the app's own wake-up for the next reminder — the half of delivery that
* used to be the provider's (#75).
*
* Exactly **one** alarm exists at a time, for the earliest reminder still ahead.
* Every firing re-scans and re-arms, so a reminder added, moved or deleted in
* between is picked up on the next pass instead of needing an alarm per reminder
* to be kept in sync with the provider's tables.
*
* A reminder that lands late is a broken reminder, hence an *exact* alarm; the
* inexact allow-while-idle fallback only applies where the OS withholds the
* capability (API 3132 with the user's permission revoked).
* 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 3132 with the permission revoked).
*/
@Singleton
class ReminderAlarmScheduler @Inject constructor(

View File

@@ -4,14 +4,9 @@ import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
/**
* One reminder as the notification layer needs it: what to show, and the stable
* [key] that identifies it across a reboot, a re-scan and a reinstall.
*
* [key] used to be the `CalendarAlerts` row id. It is now derived from the
* reminder itself (see [PlannedReminder.key]) because there is no row any more —
* in-house delivery reads `Instances` and `Reminders` and owns the alarm (#75).
* Everything downstream only ever needed it to be stable and unique, which it
* still is: it keys the notification tag, so a reminder posted twice replaces
* itself instead of stacking, and it keys the snooze/dismiss `PendingIntent`s.
* [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,

View File

@@ -9,16 +9,11 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* The read side of in-house reminder delivery: occurrences and the reminder
* offsets hanging off them, straight out of the provider's own tables.
* 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).
*
* Deliberately *not* `CalendarAlerts`. That table is the provider's own working
* copy of this same information, and the whole point of #75's second round is
* that we can no longer assume it gets written. `Instances` and `Reminders` are
* plain data the app already reads everywhere else.
*
* An interface so [ReminderScanner] can be exercised on the JVM without a
* ContentResolver, in the shape the rest of `data/calendar` uses.
* An interface so [ReminderScanner] can be exercised on the JVM.
*/
interface ReminderInstanceSource {
@@ -46,14 +41,19 @@ class ProviderReminderInstanceSource @Inject constructor(
ContentUris.appendId(this, fromMillis)
ContentUris.appendId(this, toMillis)
}.build()
// `visible` is the calendar's system flag, which the app's one visibility
// model writes (#75) — a switched-off calendar must not plan reminders.
// The status clause mirrors CalendarDataSource.instances: a cancelled
// single occurrence of a series is a real row, and NULL means "normal",
// so a bare `!= CANCELED` would drop every ordinary event.
// `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})"
"${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 ->
@@ -78,8 +78,8 @@ class ProviderReminderInstanceSource @Inject constructor(
override fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>> {
if (eventIds.isEmpty()) return emptyMap()
val out = mutableMapOf<Long, MutableList<Int>>()
// Batched because the ids go into the selection literally; an unbounded
// `IN (...)` on a busy calendar would grow the SQL past what SQLite takes.
// 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,

View File

@@ -15,13 +15,8 @@ import java.util.concurrent.TimeUnit
/**
* The backstop under the alarm: a daily scan that runs whether or not the alarm
* survived.
*
* The scan alarm re-arms itself at most a day out, so in the steady state this
* finds nothing to do. It exists for the case the whole feature is about — a
* device that quietly drops the alarm without a reboot to announce it. The
* scheduling half of reminder delivery must not have a single point of failure,
* which is exactly what the provider's broadcast turned out to be.
* 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 {
@@ -55,8 +50,8 @@ class ReminderMaintenanceWorker(
.scan()
Result.success()
} catch (e: Exception) {
// The scan swallows its own failures; anything reaching here is the
// entry point itself, which a retry will not mend. Never fail the chain.
// 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()
}

View File

@@ -54,12 +54,10 @@ class ReminderNotifier @Inject constructor(
}
/**
* The single choke point for "this calendar is switched off". The scan
* already filters on `Calendars.VISIBLE`, but two paths reach [post] around
* it: a snooze re-shown from its own alarm, armed before the calendar was
* switched off, and a read-only install whose switch lives app-side
* ([CalendarPrefs]) because it may not write the flag. Both are covered here
* rather than in either receiver.
* 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() ||
@@ -123,7 +121,7 @@ class ReminderNotifier @Inject constructor(
// POST_NOTIFICATIONS was revoked between canPost() and here.
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
}
// Handled either way: re-running it would hit the same revoked permission.
// Handled either way — a retry hits the same revoked permission.
return true
}
@@ -142,8 +140,7 @@ class ReminderNotifier @Inject constructor(
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
context,
// Shares the per-(alert, action) request-code scheme with the buttons, so
// the key's wider value range can't collide one notification's intents.
// Shares the per-(alert, action) request-code scheme with the buttons.
/* requestCode = */ ReminderActionReceiver.requestCode(
alert, ReminderActionReceiver.ACTION_OPEN,
),

View File

@@ -33,13 +33,9 @@ import javax.inject.Singleton
/**
* One pass of in-house reminder delivery: read what is planned, post what has
* come due, and arm the next wake-up.
*
* This is the whole loop. Every trigger — the alarm firing, boot, a clock or
* timezone change, an edit landing in the provider, the app starting, the daily
* safety net — runs the same [scan], so there is a single path to reason about
* and no ordering between triggers to get wrong. Re-running it is always safe:
* the watermark in [ReminderStatePrefs] decides what is owed, not the trigger.
* 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(
@@ -68,8 +64,7 @@ class ReminderScanner @Inject constructor(
try {
runScan()
} catch (e: SecurityException) {
// The calendar permission was revoked mid-flight. Nothing to
// re-arm and nothing to recover from — the next grant re-scans.
// 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)
@@ -82,18 +77,16 @@ class ReminderScanner @Inject constructor(
if (!hasReadCalendar()) return
if (!settingsPrefs.remindersEnabled.first()) {
// Reminders off: drop the wake-up, but keep the watermark moving so
// switching them back on doesn't replay everything missed meanwhile.
// 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 a little into the past as well: an event already under way can
// still owe a reminder (an all-day "at time of event" encodes to a
// negative offset, which fires after begin), and a catch-up pass needs
// to see the occurrences whose moment it missed.
// 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,
@@ -112,8 +105,8 @@ class ReminderScanner @Inject constructor(
if (notifier.canPost()) {
schedule.due.forEach { notifier.post(it.toAlert()) }
}
// Advance regardless of whether anything could be posted: a user who
// muted notifications is not owed a backlog when they unmute.
// Advance even when nothing could be posted, so muting notifications
// doesn't build a backlog.
state.setLastScanMillis(now)
alarms.scheduleScan(schedule.nextAlarmMillis)
}
@@ -123,13 +116,9 @@ class ReminderScanner @Inject constructor(
) == PackageManager.PERMISSION_GRANTED
/**
* Re-scan when the provider changes, so an event saved or deleted in the app
* re-arms the alarm immediately rather than waiting for the next pass.
*
* Debounced: a single save writes the event, its reminders and its
* attendees, and each lands as its own notification. Only useful while the
* process is alive — every other trigger covers the rest, which is why
* nothing here needs to survive it.
* 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
@@ -159,9 +148,8 @@ class ReminderScanner @Inject constructor(
const val PAST_WINDOW_MILLIS = 24L * 60 * 60 * 1000
/**
* Never wait longer than a day for the next pass, even with nothing
* pending: it rolls the lookahead window forward and re-arms an alarm
* the system may have dropped.
* 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

View File

@@ -11,22 +11,13 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Every reason to re-run a reminder scan that arrives from outside the process.
* 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.
*
* All of them do the same thing, because [ReminderScanner.scan] is idempotent
* and works out what is owed from its watermark rather than from why it was
* called:
*
* - **our own alarm** ([ACTION_SCAN]) — the ordinary case, a reminder is due;
* - **boot** and **package replaced** — both wipe pending alarms, so the app has
* to re-arm or reminders stop silently, which is the failure #75 is about;
* - **time and timezone changes** — they move every reminder relative to the
* armed alarm, and an all-day reminder's fire hour is recomposed in the
* current zone, so both need a fresh plan.
*
* Exported because the system broadcasts arrive from outside. [ACTION_SCAN] is
* ours and always sent as an explicit intent; another app triggering a scan
* early would only make it re-read the provider and re-arm, which is harmless.
* Exported for the system broadcasts; an early scan triggered by another app is
* harmless.
*/
@AndroidEntryPoint
class ReminderScheduleReceiver : BroadcastReceiver() {
@@ -34,10 +25,9 @@ class ReminderScheduleReceiver : BroadcastReceiver() {
@Inject lateinit var scanner: ReminderScanner
override fun onReceive(context: Context, intent: Intent) {
// Every action here does the same thing, but the filter still has to be
// checked: the receiver is exported, and the system broadcasts it takes
// are protected, so an intent arriving with any other action did not
// come from where it claims to.
// 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 {

View File

@@ -10,17 +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.
*
* Separate from [ReminderAlarmScheduler]'s scan alarm, and deliberately so: a
* snooze is pinned to one reminder at a time the user chose, while the scan
* alarm is a single moving wake-up for whatever comes next. A re-show also has
* to outlive the scan's watermark moving past that reminder, so it carries the
* reminder in its own intent rather than re-deriving it.
*
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
* back to an inexact allow-while-idle alarm only if the OS withholds the
* exact-alarm capability (API 3132 where the user revoked it).
* Falls back to an inexact allow-while-idle alarm where the OS withholds the
* exact-alarm capability (API 3132 with the permission revoked).
*/
@Singleton
class ReminderSnoozeScheduler @Inject constructor(
@@ -40,8 +36,8 @@ class ReminderSnoozeScheduler @Inject constructor(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} else {
// Exact alarms revoked (API 3132): an inexact wake is the honest
// best we can do without nagging for SCHEDULE_EXACT_ALARM.
// Exact alarms revoked (API 3132); an inexact wake is the best
// available without nagging for SCHEDULE_EXACT_ALARM.
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)

View File

@@ -1,15 +1,13 @@
package de.jeanlucmakiola.calendula.domain
/**
* The ways a calendar can behave unlike a plain, writable one — each of them a
* reason it is missing from the event and import pickers, and each of them
* something the app knows and used to keep to itself (#76).
* 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,
* which is why it is the one exclusion with nothing else to give it away.
* yet no event target: anything authored here is deleted by the next sync.
*/
MANAGED,
@@ -21,22 +19,17 @@ enum class CalendarStateLabel {
}
/**
* Whether the account this calendar belongs to keeps its events off the device
* (`Calendars.SYNC_EVENTS = 0`) — an "empty by construction" calendar: the rows
* simply aren't here, so nothing can display them and no reminder can fire.
*
* Device-local calendars are excluded deliberately. Nothing syncs them by
* definition, so the flag says nothing about them, and a local calendar from
* another app can hold real events at `sync_events = 0` — the same unsoundness
* that made the #75 migration guard wrong.
* 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: there are no events on the device
* to reveal, so the switch would be a control that does nothing.
* would see — it can't for a non-syncing one, with no events on the device.
*/
val CalendarSource.hasVisibilitySwitch: Boolean
get() = !isNotSynced
@@ -44,22 +37,21 @@ val CalendarSource.hasVisibilitySwitch: Boolean
/**
* Whether this calendar can be offered as a target for a new or imported event.
* The one predicate behind both pickers, so the states [CalendarStateLabel]
* names on a manager row are exactly the states that keep a calendar out of
* them (#76):
*
* - read-only has nowhere to write;
* - switched off would hide the event the moment it was saved;
* - a managed mirror has the next contact sync delete it;
* - a non-syncing one never carries the event up to the account, and
* `CalendarProvider2` wipes the calendar's rows outright when the
* subscription is switched back on — a saved event is a dead end either way.
*
* This is the test for *targets*. An event already living in an excluded
* calendar keeps it; the editor adds that calendar back to its picker.
* 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)

View File

@@ -12,22 +12,16 @@ data class CalendarVisibilityPlan(
}
/**
* Reconcile [pendingDisabledIds] — calendars switched off in Settings →
* Calendars while the app could not write `Calendars.VISIBLE`, plus whatever
* the retired app-local visibility model left behind (#75) — against the
* 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.
*
* The plan only ever *hides*. Switching a calendar off is intent the user
* expressed in Calendula, so carrying it into the provider is fair. The other
* direction is deliberately absent: a calendar hidden at system level was hidden
* somewhere else (another calendar app, the account's own settings), and
* switching it back on would un-hide it there too *and* start firing reminders
* nobody asked for. Calendula follows that flag instead and explains itself once
* (see [hasSystemHiddenCalendars]).
* 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 that need no write — already
* hidden, or gone from the device. They leave the pending set exactly as a
* successful write would.
* [CalendarVisibilityPlan.settled] carries the ids needing no write — already
* hidden, or gone from the device.
*/
fun calendarVisibilityPlan(
calendars: List<CalendarSource>,
@@ -38,8 +32,7 @@ fun calendarVisibilityPlan(
val settled = mutableSetOf<Long>()
for (id in pendingDisabledIds) {
val calendar = byId[id]
// No row means the calendar is gone; already invisible means someone
// (us, on an earlier run) got there first. Either way: nothing to write.
// 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)
@@ -47,10 +40,7 @@ fun calendarVisibilityPlan(
/**
* Whether any calendar is switched off at system level without Calendula having
* asked for it. Those calendars showed their events before the app adopted
* `Calendars.VISIBLE` as its one visibility model and no longer do, which is
* what the one-time notice explains — the alternative, switching them on, would
* reach into every other calendar app on the device.
* asked for it — the condition the one-time notice explains.
*/
fun hasSystemHiddenCalendars(
calendars: List<CalendarSource>,

View File

@@ -1,12 +1,8 @@
package de.jeanlucmakiola.calendula.domain
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cbrt
import kotlin.math.hypot
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sqrt
import de.jeanlucmakiola.calendula.domain.color.Oklch
import de.jeanlucmakiola.calendula.domain.color.eventTone
import de.jeanlucmakiola.calendula.domain.color.oklchOf
/**
* Curates an account's published event palette for the colour picker.
@@ -18,24 +14,25 @@ import kotlin.math.sqrt
* (#22).
*
* Crucially, curation runs against the colour the picker actually *paints*, not
* the raw provider value. The picker softens every swatch through [pastelArgb]:
* it pins lightness to a constant and caps saturation, so the raw palette's
* lightness axis is invisible on screen. Two raw colours that look different —
* a navy and a mid blue — paint as one swatch, and every neutral (black, the
* grays, white) paints as the same pale tint. Judging distinctness in raw
* space, as before, left near-identical painted swatches and stranded the
* neutrals as a run of look-alike "pinks" at the end of the grid.
* the raw provider value — and it gets that colour from the same [eventTone]
* the picker calls, rather than from a copy of its shaping kept in step by hand.
* Because a harmonised container pins lightness, the raw palette's lightness
* axis is invisible on screen: two raw colours that look different — a navy and
* a mid blue — paint as one swatch, and every neutral (black, the grays, white)
* paints as the same grey. Judging distinctness in raw space, as before, left
* near-identical painted swatches and stranded the neutrals as a run of
* look-alike tints at the end of the grid.
*
* Three steps, all in painted space:
* 1. Collapse swatches that paint identically to one (alphabetically-first key
* wins, deterministically) — this folds aliases, dark/light shades of a
* hue, and all the neutrals together.
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out
* neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are
* then thinned to visually distinct colours: most vivid first, a colour is
* kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every
* colour already kept. Small palettes are already curated by their adapter
* and pass through whole.
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the neutral-origin
* swatches — with lightness pinned, a grey source paints as plain grey, so
* "has no hue left" is simply zero chroma — and are then thinned to visually
* distinct colours: most vivid first, a colour is kept only when at least
* [MIN_DISTANCE] away in Oklab from every colour already kept. Small
* palettes are already curated by their adapter and pass through whole.
* 3. The survivors are ordered like a rainbow — continuously by painted hue —
* with the wheel cut at its single widest empty gap so the one unavoidable
* seam lands in dead space and no hue family is torn across both ends.
@@ -45,12 +42,12 @@ import kotlin.math.sqrt
*/
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
val painted = sortedBy { it.key }
.distinctBy { pastelArgb(it.argb) }
.map { it to Lab.of(pastelArgb(it.argb)) }
.distinctBy { paintedArgb(it.argb) }
.map { it to oklchOf(paintedArgb(it.argb)) }
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
painted
} else {
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
thin(painted.filter { (_, painted) -> painted.chroma > 0f })
}
return orderAroundWheel(kept).map { (option, _) -> option }
}
@@ -61,17 +58,17 @@ fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
* instead of mid-family. Saturation breaks ties, vivid first.
*/
private fun orderAroundWheel(
swatches: List<Pair<EventColorOption, Lab>>,
): List<Pair<EventColorOption, Lab>> {
swatches: List<Pair<EventColorOption, Oklch>>,
): List<Pair<EventColorOption, Oklch>> {
if (swatches.size < 2) return swatches
val byHue = swatches.sortedWith(
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }),
compareBy({ (_, painted) -> painted.hue }, { (_, painted) -> -painted.chroma }),
)
// Split the wheel after the largest empty arc between neighbouring hues;
// the default is the wrap gap (last hue back round to the first), i.e. the
// familiar 0→360 order, and we only rotate away from it for a wider void.
var cutAfter = byHue.lastIndex
var widestGap = 360.0 - byHue.last().second.hue + byHue.first().second.hue
var widestGap = 360f - byHue.last().second.hue + byHue.first().second.hue
for (i in 0 until byHue.lastIndex) {
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
if (gap > widestGap) {
@@ -84,102 +81,32 @@ private fun orderAroundWheel(
/** Greedy max-distance filter: vivid colours stake out clusters first. */
private fun thin(
swatches: List<Pair<EventColorOption, Lab>>,
): List<Pair<EventColorOption, Lab>> {
swatches: List<Pair<EventColorOption, Oklch>>,
): List<Pair<EventColorOption, Oklch>> {
val byVividness = swatches
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
.sortedWith(
compareByDescending<Pair<EventColorOption, Oklch>> { it.second.chroma }
.thenBy { it.first.key },
)
val kept = mutableListOf<Pair<EventColorOption, Oklch>>()
for (candidate in byVividness) {
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
if (kept.none { it.second.distanceTo(candidate.second) < MIN_DISTANCE }) kept += candidate
}
return kept
}
/**
* The softening the colour picker paints over every swatch: keep the hue, scale
* and clamp saturation into a gentle band, and pin value to a constant so
* nothing screams and everything reads on the surface. Value is fixed here so
* curation is theme-independent — only hue and saturation distinguish painted
* swatches.
*
* This is a self-contained mirror of floret-kit's `pastelize` hue/saturation
* shaping (`de.jeanlucmakiola.floret.components.pastelize`), with value pinned
* rather than theme-picked. Curation must reason about the colour the picker
* paints, so the two shapings have to agree: if floret's saturation band or
* curve changes, update this in step.
*/
fun pastelArgb(rawArgb: Int): Int {
val r = ((rawArgb shr 16) and 0xFF) / 255f
val g = ((rawArgb shr 8) and 0xFF) / 255f
val b = (rawArgb and 0xFF) / 255f
val max = maxOf(r, g, b)
val min = minOf(r, g, b)
val delta = max - min
val hue = when {
delta == 0f -> 0f
max == r -> 60f * (((g - b) / delta) % 6f)
max == g -> 60f * (((b - r) / delta) + 2f)
else -> 60f * (((r - g) / delta) + 4f)
}.let { if (it < 0f) it + 360f else it }
val sat = (if (max == 0f) 0f else delta / max) * 0.6f
val s = sat.coerceIn(0.25f, 0.65f)
val v = PASTEL_VALUE
val c = v * s
val x = c * (1f - abs((hue / 60f) % 2f - 1f))
val m = v - c
val (rr, gg, bb) = when {
hue < 60f -> Triple(c, x, 0f)
hue < 120f -> Triple(x, c, 0f)
hue < 180f -> Triple(0f, c, x)
hue < 240f -> Triple(0f, x, c)
hue < 300f -> Triple(x, 0f, c)
else -> Triple(c, 0f, x)
}
fun channel(value: Float) = ((value + m) * 255f).roundToInt().coerceIn(0, 255)
return (0xFF shl 24) or (channel(rr) shl 16) or (channel(gg) shl 8) or channel(bb)
}
/** Reference lightness for curation; the picker paints at this on dark surfaces. */
private const val PASTEL_VALUE = 0.82f
/** The colour the picker paints for [argb]; the light theme stands in as the
* reference, since a harmonised container differs only in lightness by theme
* and curation compares hue and chroma. */
private fun paintedArgb(argb: Int): Int =
eventTone(argb, dark = false, harmonise = true).container
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
private const val CURATION_TRIGGER_SIZE = 36
/** Minimum CIE76 ΔE between surviving painted swatches. */
private const val MIN_DELTA_E = 13.0
/**
* Painted-chroma floor for oversized palettes: below this a swatch is a washed-
* out tint — the neutrals and near-whites the saturation clamp muddies — so it
* is dropped rather than shown as pale filler.
* 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 PASTEL_CHROMA_FLOOR = 22.0
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
private class Lab(val l: Double, val a: Double, val b: Double) {
val chroma: Double get() = hypot(a, b)
/** Hue angle in degrees, 0360, around the Lab a-b plane. */
val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
fun deltaE(other: Lab): Double =
sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
companion object {
fun of(argb: Int): Lab {
fun linear(shift: Int): Double {
val c = ((argb shr shift) and 0xFF) / 255.0
return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
}
val r = linear(16)
val g = linear(8)
val b = linear(0)
val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0
val fy = f(y)
return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z)))
}
}
}
private const val MIN_DISTANCE = 0.025f

View File

@@ -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,
/** 112, 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
}

View File

@@ -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))
}

View File

@@ -15,7 +15,9 @@ import kotlin.time.Instant
* calendar this way to create a new event, passing the fields as
* [android.provider.CalendarContract] extras. Any field the intent omits falls
* back to the same defaults the in-app "new event" uses — a timed start at the
* next full hour and a one-hour duration. [EventForm.calendarId] is left null so
* next full hour, and a placeholder hour for a missing end that the form then
* stretches to the default-duration setting (the intent is opened as
* `ImportSource.InsertOpenEnded`, #54). [EventForm.calendarId] is left null so
* it resolves to the last-used / first-writable calendar, exactly like the
* `.ics` single-event and plain new-event paths.
*

View File

@@ -13,11 +13,9 @@ data class CalendarSource(
val accountType: String,
val color: Int,
/**
* The system's per-calendar `Calendars.VISIBLE` flag — the single visibility
* model: it decides both what Calendula shows and whether the provider
* schedules this calendar's reminder alarms at all (#75). Settings →
* Calendars writes it; the drawer's filter sheet is a separate, purely
* in-app declutter that leaves reminders alone.
* 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,
/**
@@ -47,10 +45,8 @@ data class CalendarSource(
val isManaged: Boolean = false,
/**
* Whether the provider keeps this calendar's events on the device
* (`Calendars.SYNC_EVENTS`). Independent of [isVisibleInSystem]. For a
* synced account it means the events aren't stored locally at all, so the
* calendar reads as permanently empty; a device-local calendar another app
* created can hold events with the flag off, so it says nothing there.
* (`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,
@@ -66,6 +62,18 @@ 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 answered "no" to the invitation
* (`Events.SELF_ATTENDEE_STATUS`). 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).
*/
val isDeclined: Boolean = false,
)
/**
@@ -77,12 +85,9 @@ data class EventInstance(
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
/**
* The zone this event's calendar dates live in. Timed events are resolved in the
* device [zone]; all-day events live at UTC midnights with an exclusive end, so
* resolving them anywhere else shifts the day boundaries — east of UTC the end
* leaks onto the following day (#65), west of UTC the start pulls back onto the
* previous one (#82). Every surface that has to name an all-day event's date
* goes through here, so grid, agenda, detail and search cannot disagree.
* 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
@@ -92,9 +97,8 @@ fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
start.toLocalDateTime(dateZone(zone)).date
/**
* The last calendar day this event actually occupies. An event ending exactly at
* midnight (all-day events end at the exclusive next-midnight) does not reach
* into that boundary day, so resolve the instant just before [EventInstance.end].
* 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
@@ -132,6 +136,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,
)
/**

View File

@@ -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",

View File

@@ -10,31 +10,18 @@ import kotlinx.datetime.plus
/**
* The first [limit] dates a [SimpleRecurrence] fires on, starting at [start]
* (the event's own start date, i.e. DTSTART), for showing a rule as dates
* instead of as words.
* (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.
*
* A recurrence rule is the one place in the editor where a correct-sounding
* phrase can still mean something the user didn't intend — "every 2 weeks on
* Mon, Fri", "monthly" on the 31st — and the only honest answer is the dates
* themselves. This is a *preview*, deliberately kept to the shapes the picker
* can build; the provider remains the authority on what actually gets stored
* and expanded.
* 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.
*
* The rules it mirrors, all from RFC 5545 (the spec the platform's recurrence
* engine implements):
* - a monthly or yearly rule **skips** a period the start day doesn't exist in,
* rather than sliding to the month's last day: the 31st recurring monthly
* lands only in 31-day months, 29 February yearly only in leap years.
* - a weekly rule with weekday picks repeats in blocks of `interval` weeks,
* with the week beginning on **Monday** — the RFC's default WKST, and the one
* that applies here because [toRRule] never writes a WKST part. The
* display-only "first day of week" preference deliberately doesn't enter into
* it, or the preview would disagree with the stored rule.
* - [RecurrenceEnd.Count] counts real occurrences (skipped periods don't
* consume one), [RecurrenceEnd.Until] is inclusive of its date.
*
* Returns fewer than [limit] dates when the series ends first, and an empty
* list when the rule yields nothing at all (an UNTIL before the start).
* 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()
@@ -42,15 +29,17 @@ fun SimpleRecurrence.upcomingOccurrences(start: LocalDate, limit: Int): List<Loc
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()
val result = mutableListOf<LocalDate>()
// 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 that produce nothing (a skipped 31st, a weekday block whose picks
// all fall before the start) must not stall the walk, so the cap is on
// periods examined rather than on dates found.
// 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 (date <= start) continue
if (until != null && date > until) return result
result += date
if (result.size == wanted) return result
@@ -67,8 +56,8 @@ private fun SimpleRecurrence.occurrencesInPeriod(period: Int, start: LocalDate):
RecurrenceFreq.Weekly -> weeklyOccurrences(period, start)
RecurrenceFreq.Monthly -> {
val month = start.plus(period * interval, DateTimeUnit.MONTH)
// plus() clamps the day into the shorter month; the rule instead
// skips such a period, so a clamped date means "not this month".
// 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 ->
@@ -96,13 +85,9 @@ private fun SimpleRecurrence.weeklyOccurrences(period: Int, start: LocalDate): L
}
/**
* Whether a run of [occurrences] starting at [start] leaves its starting year
* i.e. whether showing them without a year would be ambiguous.
*
* Day and month alone are enough for a rule that stays inside one year, and
* dropping the year keeps the line short. They are actively misleading
* otherwise: a yearly rule reads as the same date three times over, and a
* monthly one rolling past December gives no hint that it has.
* 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
@@ -114,9 +99,8 @@ private fun dateOrNull(year: Int, month: Int, day: Int): LocalDate? =
private const val DAYS_PER_WEEK = 7
/**
* How many repetitions to examine before giving up. Generous enough for the
* sparsest rule the picker can build (29 February yearly with a 999-year
* interval is nonsense; 31st monthly needs at most a handful), and bounded so a
* rule whose occurrences all fall outside its own UNTIL can't spin.
* 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

View File

@@ -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(";")
}

View File

@@ -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

View File

@@ -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

View File

@@ -8,21 +8,8 @@ import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
/**
* Works out *when* each reminder has to fire and *which* ones are due, with no
* provider and no clock of its own — the whole decision layer of in-house
* reminder delivery (#75).
*
* Calendula used to leave both halves to the calendar provider: it scheduled the
* alarms, wrote the `CalendarAlerts` rows, and broadcast `EVENT_REMINDER` at the
* right moment. That chain is intact on stock Android but demonstrably not on
* every device — AOSP's own unbundled calendar carries three separate
* workarounds for OEMs that retarget the broadcast, or that only write the alert
* row at alert time. An app that can only *react* to that broadcast has no way
* to notice it never came.
*
* So the offsets in `CalendarContract.Reminders` are now read as data and turned
* into alarms we own. Everything here is pure: instances and reminder offsets in,
* fire instants out.
* 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`. */
@@ -46,11 +33,8 @@ data class PlannedReminder(
val alarmMillis: Long,
) {
/**
* Stable identity of this reminder, derived from what defines it rather
* than from a provider row id (there is none any more). It keys the
* notification tag and the snooze/dismiss `PendingIntent`s, so it has to
* survive a reboot, a re-scan and a reinstall — the same reminder must land
* on the same notification instead of stacking a second one.
* 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)
@@ -75,28 +59,10 @@ private const val MINUTES_PER_DAY = 1_440
/**
* Pair every instance with each of its event's reminder offsets.
*
* A **timed** occurrence is trivial: `begin` is an absolute instant, so
* `begin minutes` is exact by construction, in any timezone, across any DST
* boundary.
*
* An **all-day** occurrence is not, and taking the offset at face value is what
* makes reminders land at the wrong hour. Its `begin` is UTC midnight, and the
* stored offset is not a plain lead time — `AllDayReminderEncoding` folds the
* wanted wall-clock hour into it, sampled against *one* date's UTC offset. Fire
* at `begin minutes` and every occurrence in a different DST phase than the one
* that was sampled drifts by the offset delta, an hour early in one direction and
* an hour late in the other. Rows written by other apps carry no wall-clock at
* all — a conventional `1440` fires at UTC midnight, which is 01:00 or 02:00
* local in Berlin and the wrong day west of UTC.
*
* So the offset is only read for *which day* it means, via [allDayLeadDays], and
* the hour comes from [allDayTimeMinutes] — the one global "show all-day
* reminders at" setting — recomposed against each occurrence's own date in
* [zone]. 09:00 Berlin is then 09:00 Berlin on every occurrence, whatever the
* offset was when the row was written.
*
* [minutesByEvent] may hold duplicate offsets (two identical reminder rows on one
* event); they collapse, because they would otherwise fight over one notification.
* Timed occurrences fire at `begin minutes`. All-day ones read the offset only
* for *which day* it means ([allDayLeadDays]) and take the hour from
* [allDayTimeMinutes], recomposed against each occurrence's own date in [zone].
* Duplicate offsets in [minutesByEvent] collapse.
*/
fun planReminders(
instances: List<ReminderEventInstance>,
@@ -124,30 +90,15 @@ private fun allDayDate(beginMillis: Long): LocalDate =
/**
* How many whole days before its occurrence a raw all-day offset means.
*
* Normally the local date of the encoded fire instant answers it: our own rows
* fold the wanted hour into the offset, so that date *is* the day the reminder
* belongs to, whatever the offset was when it was written.
* 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.
*
* A plain multiple of 1440 is the exception. Those come from calendar apps that
* store a bare lead time against UTC midnight, and east of UTC both readings
* agree anyway — but west of UTC the instant falls on the previous local date,
* so the day count has to be taken at face value or it comes out one too many.
*
* Except when the row is one of ours after all: our offset lands on a multiple
* whenever the all-day hour equals the zone's UTC offset (20:00 in New York,
* 19:00 an hour further west, and so on), and reading those at face value fired
* them a day late — a "1 day before" arriving on the morning of the event. So
* the hour decides, not the shape of the number: an instant landing on the hour
* the setting names is ours. Within an hour or so of it counts, because a fixed
* offset written in one DST phase drifts by the offset delta in the other.
*
* The two are genuinely indistinguishable in that band — the encodings collide
* exactly there — so the tie goes to the reading that honours the lead time the
* user chose in this app.
*
* [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes] calls
* this for display, so the notification arrives on the day the event screen says
* it will.
* Also used by
* [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes] for
* display, so screen and notification agree.
*/
internal fun allDayLeadDays(
rawMinutes: Int,
@@ -185,17 +136,10 @@ private fun allDayAlarmMillis(
/**
* Split [planned] into what is due now and when to wake up next.
*
* Due means the fire instant falls in `(lastFiredMillis, nowMillis]` — a
* half-open watermark, so a scan triggered twice cannot post the same reminder
* twice, while a scan that runs late still catches everything the missed alarm
* would have posted. That catch-up is the point: an alarm dropped by a reboot,
* an app update or a doze window is recovered by the next scan rather than lost.
*
* A reminder whose event has already ended is dropped rather than posted late —
* see [isStillRelevant].
*
* [nextAlarmMillis] is capped at [horizonMillis] even when nothing is pending, so
* the scan re-runs at least that often and the lookahead window rolls forward.
* 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>,
@@ -225,31 +169,20 @@ fun ReminderEventInstance.isStillRelevant(nowMillis: Long): Boolean =
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
/**
* The watermark a scan at [nowMillis] should measure against, given what the
* last one recorded.
*
* A first-ever scan ([lastScanMillis] `null`) claims the present, so an install
* or an upgrade onto in-house delivery does not treat every reminder since the
* epoch as overdue and bury the user in notifications. A watermark in the
* *future* — the clock was moved back, or the user travelled across the date
* line — is clamped for the mirror-image reason: left alone it would silence
* every reminder until real time caught up with it.
* 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 for [scheduleReminders] to see every
* reminder in time: the plain lookahead plus the longest offset any reminder row
* carries, so a "two weeks before" reminder is planned before it comes due
* instead of firing late (the limitation Etar's equivalent documents).
*
* The stretch is capped at [MAX_REMINDER_LEAD_MILLIS]. `maxReminderMinutes` is
* whatever the largest row in the whole provider says, across every calendar and
* whoever wrote it — an imported `TRIGGER:-P100W`, or a sync adapter writing
* nonsense, would otherwise make every scan (launch, every provider change, every
* alarm) expand every recurring series over years. A lead beyond the cap is not
* delivered; a year is far past anything the picker composes.
* 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)

View File

@@ -10,6 +10,7 @@ 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.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -27,6 +28,10 @@ import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
import de.jeanlucmakiola.calendula.ui.common.drillToDay
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.selectView
@@ -75,6 +80,7 @@ fun CalendarHost(
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
requestedInsertForm: EventForm? = null,
requestedInsertSource: ImportSource = ImportSource.Insert,
onInsertConsumed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
@@ -149,9 +155,8 @@ fun CalendarHost(
// over Settings and survives view switches.
var showCalendars by rememberSaveable { mutableStateOf(false) }
// Backup & restore (#69) — like the manager, driven by the calendar list
// rather than by preferences, so it is hoisted here instead of living as a
// Settings sub-section. Reached from Settings and from the manager.
// 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:
@@ -204,7 +209,7 @@ fun CalendarHost(
// reveals on top of whatever was open without extra dismissal.
LaunchedEffect(requestedInsertForm) {
if (requestedInsertForm != null) {
importFormSource = ImportSource.Insert
importFormSource = requestedInsertSource
importForm = requestedInsertForm
onInsertConsumed()
}
@@ -294,6 +299,33 @@ 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,
inFlight = reschedule.inFlight,
undoStarted = reschedule.undoStarted,
edit = onEditEvent,
)
}
val slideSpec = rememberCalendarSlideSpec()
// Base-level back: pop the view stack while no overlay covers it (each overlay
@@ -311,6 +343,7 @@ fun CalendarHost(
// navigation, so it fades through rather than sliding — paging *within* a
// view keeps the directional slide. AnimatedContent keyed on the view type.
val viewSwitch = fadeThrough()
CompositionLocalProvider(LocalEventMove provides moveScope) {
AnimatedContent(
targetState = view,
transitionSpec = { viewSwitch },
@@ -367,6 +400,11 @@ fun CalendarHost(
)
}
}
}
// 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.
@@ -486,10 +524,8 @@ fun CalendarHost(
)
}
// Calendar manager — declared last so it covers every overlay that can
// open it: Settings, both event forms, and the .ics import picker (#76).
// Coming back from it leaves the caller exactly as it was, with the
// calendar list already refreshed by the provider's notification.
// 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(),
@@ -509,12 +545,15 @@ fun CalendarHost(
) {
BackupScreen(
onBack = { showBackup = false },
// Restoring runs the normal .ics import, and that way round this
// screen has to step aside: declared above the import overlays,
// it would otherwise cover the screen it just asked for. Closing
// it hands the user back to whatever opened Backup once the
// import is done.
onImport = { importUri = it; importForceMany = true; showBackup = false },
// Restore runs the normal .ics import, and both this screen and
// the manager that can have opened it are declared above the
// import overlays — so both have to step aside.
onImport = {
importUri = it
importForceMany = true
showBackup = false
showCalendars = false
},
)
}
}

View File

@@ -53,4 +53,18 @@ class CalendarHostViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
/**
* Whether events may be dragged to another slot to reschedule them (#68, #173).
* Off until the stored value arrives, even though the preference itself defaults
* on: the wrong guess costs an enabled user a gesture for the few frames a cold
* start takes to read DataStore, but re-arms the one thing a disabled user
* switched off to be rid of.
*/
val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
}

View File

@@ -2,12 +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
@@ -22,13 +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.calendars.CalendarVisibilityNoticeDialog
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
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,
@@ -39,6 +51,8 @@ fun RootScreen(
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
onInsertConsumed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
@@ -51,8 +65,8 @@ fun RootScreen(
)
}
// Whether the app came up already holding it — a launch scan has covered
// that case, so only a grant made during this session owes a re-scan.
// 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
@@ -74,58 +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()
// One-time explainer for the switch to the device's own calendar
// visibility (#75); armed by the reconciler, shown over the app.
val visibilityNotice: CalendarVisibilityNoticeViewModel = hiltViewModel()
val noticePending by visibilityNotice.pending.collectAsStateWithLifecycle()
// Runs on entry however the permission was granted — including from
// Android's app-settings screen, which only comes back through the
// ON_RESUME check above. Cheap once there is nothing left to do.
LaunchedEffect(Unit) {
visibilityNotice.reconcile()
if (!grantedAtLaunch) reminderOnboarding.rearmAfterGrant()
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,
)
}
if (onboardingDone == true && noticePending) {
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
}
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
when (done) {
true -> CalendarHost(
modifier = modifier,
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = onDetailKeyConsumed,
widgetNavRequest = widgetNavRequest,
onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed,
requestedInsertForm = requestedInsertForm,
onInsertConsumed = onInsertConsumed,
requestedEditKey = requestedEditKey,
onEditKeyConsumed = onEditKeyConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,
modifier = modifier,
)
null -> {}
}
}
} 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,
)
}
}
}
}

View File

@@ -92,20 +92,27 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
* - everything else → "start end" ("27 Jun 3 Jul 2026"), with the start's
* year shown too only when it differs from the end's.
*
* [monthAsSpan] puts [AgendaRange.ThisMonth] on the "start end" form as well.
* The agenda's own header browses the whole month, so the month name is right
* there; the range picker previews the window an option opens *today*, which for
* "This month" is only the rest of it.
*/
fun agendaRangeWindowSummary(
range: AgendaRange,
start: LocalDate,
end: LocalDate,
locale: Locale,
monthAsSpan: Boolean = false,
): String {
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
val dayMonth = localizedDateFormatter(locale, "dMMM")
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
return when (range) {
AgendaRange.Day -> dayMonthYear.format(javaStart)
AgendaRange.ThisMonth -> localizedDateFormatter(locale, "LLLLy").format(javaStart)
return when {
range == AgendaRange.Day -> dayMonthYear.format(javaStart)
range == AgendaRange.ThisMonth && !monthAsSpan ->
localizedDateFormatter(locale, "LLLLy").format(javaStart)
else -> {
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
"${startFmt.format(javaStart)} ${dayMonthYear.format(javaEnd)}"

View File

@@ -24,13 +24,16 @@ 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.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.components.GroupedRow
@@ -128,8 +131,8 @@ internal fun AgendaEventRow(
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow(
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
title = title,
summary = agendaTimeSummary(event, day, zone),
title = declinedTitle(title, event.isDeclined),
summary = AnnotatedString(agendaTimeSummary(event, day, zone)),
position = position,
minHeight = 64.dp,
leading = {
@@ -137,7 +140,7 @@ internal fun AgendaEventRow(
modifier = Modifier
.size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp))
.background(eventFill(event.color, dark, soften)),
.background(eventAccent(event.color, dark, soften)),
)
},
onClick = onClick,

View File

@@ -112,6 +112,7 @@ fun AgendaScreen(
CalendarDrawer(
currentView = selectedView,
currentDate = anchor,
drawerState = drawerState,
viewOrder = drawerViewOrder,
onSelectView = { view ->
onSelectView(view)
@@ -275,7 +276,7 @@ private fun AgendaRangeBanner(
}
@Composable
private fun AgendaContent(
internal fun AgendaContent(
state: AgendaUiState,
pastDisplay: PastEventDisplay,
showToday: Boolean,

View File

@@ -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,
)
}

View File

@@ -59,10 +59,8 @@ import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import java.time.LocalDate
// SAF mime filter for the restore picker. `.ics` files reach us under several
// mimes depending on the source app (our own export uses text/calendar; others
// hand them out as octet-stream or text/plain), so accept the common set rather
// than hide valid backups behind an over-tight filter.
// 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",
@@ -70,11 +68,8 @@ private val RESTORE_MIME_TYPES = arrayOf(
)
/**
* Backup & restore (#69). Local calendars aren't synced anywhere, so a `.ics`
* export is their only safety net — that made it a data-safety feature hiding in
* the calendar manager, where nobody went looking. It now has its own Settings
* entry, sharing [CalendarsViewModel] with the manager (both are driven by the
* same calendar list).
* 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.
@@ -92,29 +87,24 @@ fun BackupScreen(
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
// Export covers the user's own local calendars (managed special-dates
// mirrors don't count — they are rebuilt from contacts). Restore can target
// any calendar the import picker would offer, so its availability is broader.
// Export covers local calendars only; managed special-dates mirrors are
// rebuilt from contacts. Restore can target anything the import picker offers.
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
val canImport = calendars.any { it.isEventTarget }
// SAF "create document" target for the backup file. The picked Uri is handed
// to the VM to stream the .ics into. This launcher exports everything
// eligible (null); the per-calendar selector owns its own launcher.
// Exports everything eligible (null); the per-calendar selector owns its
// own launcher.
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri -> uri?.let { viewModel.exportBackup(it, null) } }
var showExportPicker by rememberSaveable { mutableStateOf(false) }
// SAF "open document" picker for restoring events from a .ics file. The
// picked Uri is handed up to the host, which runs it through the same import
// flow as an externally opened .ics (parse, dedup by UID, target picker).
// Restore runs the picked file through the normal .ics import flow.
val openBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri -> uri?.let(onImport) }
// SAF folder picker for the automatic-backup destination; the VM persists the
// write grant so background runs can keep writing to it.
// The VM persists the write grant so background runs can keep writing.
val pickFolder = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { uri -> uri?.let(viewModel::setAutoBackupFolder) }
@@ -148,15 +138,12 @@ fun BackupScreen(
HintText(stringResource(R.string.calendars_backup_hint))
if (exportable.isNotEmpty()) {
// One connected card: the one-time export on top, restore under it,
// then automatic backup (and its folder/interval rows when on).
GroupedRow(
title = stringResource(R.string.calendars_backup_action),
position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
onClick = {
// With more than one exportable calendar, let the user choose
// which to include; a single one exports straight away.
// A single exportable calendar skips the selector.
if (exportable.size == 1) {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
} else {
@@ -198,9 +185,8 @@ fun BackupScreen(
HintText(backupStatusText(autoBackup.status))
}
} else if (canImport) {
// Nothing to back up (no writable local calendar), but events can
// still be restored into a writable calendar — offer restore on its
// own so it isn't hidden behind export eligibility.
// Nothing to back up, but restore is still possible — don't hide
// it behind export eligibility.
SectionHeader(stringResource(R.string.calendars_restore_header))
HintText(stringResource(R.string.calendars_restore_hint))
GroupedRow(
@@ -239,11 +225,8 @@ private fun ExportCalendarPicker(
onExport: (Uri, Set<Long>?) -> Unit,
onDismiss: () -> Unit,
) {
// Seed once with everything selected and hold it across recomposition and
// rotation. NOT keyed on [calendars]: the list is observer-driven, so keying
// it would silently reset the user's de-selections whenever the provider
// re-emits (a background sync, a recolor). Ids that later vanish are harmless
// — the data layer intersects the chosen set with the eligible calendars.
// 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() },
@@ -348,7 +331,7 @@ private fun BackupIntervalDialog(
onConfirm: (Long) -> Unit,
onDismiss: () -> Unit,
) {
// minutes-per-unit for each entry; pick the largest unit the current value divides into.
// 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)

View File

@@ -1,76 +0,0 @@
package de.jeanlucmakiola.calendula.ui.calendars
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The one-time notice that Calendula now follows the device's per-calendar
* visibility (#75). Armed by `CalendarVisibilityReconciler` on the first launch
* that finds a calendar switched off outside the app — those used to show their
* events here and no longer do, and the app deliberately does not switch them
* back on, because that would un-hide them in every other calendar app too.
*/
@HiltViewModel
class CalendarVisibilityNoticeViewModel @Inject constructor(
private val prefs: CalendarPrefs,
private val reconciler: CalendarVisibilityReconciler,
) : ViewModel() {
/**
* Reconcile whenever the app comes up with the calendar permission held.
* The launch itself is covered by `CalendulaApp`, but a permission granted
* on Android's app-settings screen comes back through `RootScreen`'s
* ON_RESUME and never touches the permission screen's callback — so the
* trigger hangs off "we are showing the app", not off one grant route.
* Settled runs cost two DataStore reads and stop there.
*/
fun reconcile() {
viewModelScope.launch { reconciler.run() }
}
val pending: StateFlow<Boolean> = prefs.visibilityNoticePending
.map { it == true }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
fun dismiss() {
viewModelScope.launch { prefs.setVisibilityNoticePending(false) }
}
}
/** Plain informational dialog — one acknowledgement, nothing to decide. */
@Composable
fun CalendarVisibilityNoticeDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.VisibilityOff, contentDescription = null) },
title = { Text(stringResource(R.string.calendars_visibility_notice_title)) },
text = { Text(stringResource(R.string.calendars_visibility_notice_message)) },
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_ok))
}
},
)
}

View File

@@ -88,6 +88,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
@@ -107,14 +108,11 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
/**
* Calendar manager (reached from Settings). Lists the app's own device-only
* calendars with create / rename / recolor / delete (via a full-screen editor),
* and lists synced calendars read-only with a per-account "manage in the source
* app" deep-link — the app never touches a synced calendar's server.
* calendars with create / rename / recolor / delete, and synced calendars
* read-only with a per-account "manage in the source app" deep-link.
*
* Export/import lives in its own Settings entry ([BackupScreen], #69); this
* screen only points at it, because the two are looked for separately: "which
* calendars do I have" versus "keep a copy of them". A full-screen destination;
* [onBack] pops it.
* screen only points at it. [onBack] pops the destination.
*/
@Composable
fun CalendarsScreen(
@@ -252,9 +250,7 @@ private fun CalendarsList(
}
}
// Backup lives in its own Settings entry now (#69) — this row is the
// pointer, so someone looking at their local calendars still finds the
// way to keep a copy of them.
// Pointer to the Backup entry (#69), so it stays findable from here.
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.settings_section_backup),
@@ -314,8 +310,8 @@ private fun CalendarsList(
onSetAccountVisible(switchable.map { it.id }, enabled)
},
) {
// Calendars you can act on first; the ones this device isn't
// syncing sit at the bottom, dimmed and switchless.
// Actionable calendars first; non-syncing ones at the
// bottom, dimmed and switchless.
val ordered = cals.orderedForManager()
ordered.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
@@ -392,11 +388,8 @@ private fun CalendarEditor(
},
actions = {
if (!isNew) {
// Kept in place while the special-dates sync owns this
// calendar, rather than hidden: the button is where you
// expect it, disabled, with the card below saying why —
// and it comes back to life the moment the feature is
// off, when the delete would actually stick.
// Disabled rather than hidden while the special-dates
// sync owns this calendar; the card below says why.
IconButton(
onClick = { confirmDelete = true },
enabled = !deleteLocked,
@@ -450,7 +443,7 @@ private fun CalendarEditor(
)
}
}
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
InlineTextField(
value = name,
onValueChange = { name = it },
@@ -523,9 +516,7 @@ private fun CalendarEditor(
/**
* The row's supporting line: the states that make this calendar behave unlike a
* plain writable one (#76), then its own description. Text rather than badges —
* a row can carry several of these at once next to a switch, which is exactly
* what M3 supporting text composes and a row of static chips doesn't.
* plain writable one (#76), then its own description.
*/
@Composable
private fun calendarRowSummary(calendar: CalendarSource): String? {
@@ -543,12 +534,9 @@ private fun calendarRowSummary(calendar: CalendarSource): String? {
}
/**
* The per-row on/off control, writing the system's `Calendars.VISIBLE`: checked
* = the calendar is shown, unchecked = it drops out of every surface (events,
* filters, pickers) and the provider stops scheduling its reminders. The flag is
* device-local — nothing is deleted and nothing is synced anywhere. Carries its
* own content description so the toggle is self-describing to screen readers
* even on a dimmed row.
* The per-row on/off control, writing the system's device-local
* `Calendars.VISIBLE`. Unchecked drops the calendar out of every surface and
* stops its reminders. Carries its own content description.
*/
@Composable
private fun EnableSwitch(

View File

@@ -73,18 +73,10 @@ class CalendarsViewModel @Inject constructor(
)
/**
* Managed special-dates calendars whose deletion would not stick. While the
* feature is on, the sync owns every mirror: it recreates a missing one for
* an enabled type on the next pass and deletes the leftover of a disabled
* one (`SpecialDatesSyncEngine.reconcileCalendars`), so either way the
* delete would appear to work and then undo itself. Turning special dates
* off empties this set, and deleting a leftover mirror is a real delete from
* then on.
*
* Read off each calendar's own durable marker ([CalendarSource.isManaged],
* the `CAL_SYNC2` one the editor lock already trusts) rather than the stored
* ids, which are only rewritten on the next sync pass — a preferences loss
* would otherwise unlock a live mirror until then.
* Managed special-dates calendars whose deletion would not stick: while the
* feature is on, `SpecialDatesSyncEngine.reconcileCalendars` undoes it on
* the next pass. Read off each calendar's durable [CalendarSource.isManaged]
* marker rather than the stored ids, which lag a sync pass behind.
*/
val deleteLockedCalendarIds: StateFlow<Set<Long>> = combine(
calendars,
@@ -147,27 +139,18 @@ class CalendarsViewModel @Inject constructor(
}
/**
* Switch a calendar on or off. This is the app's one visibility model: it
* writes the system's `Calendars.VISIBLE`, so the calendar disappears from
* every surface *and* the provider stops (or resumes) scheduling its
* reminders. Nothing is patched by hand — the provider notifies and the
* observer re-queries.
*
* A reminder that came due while the calendar was off stays gone when it is
* switched back on. Delivery plans from `Instances` and `Reminders` on every
* scan (#75), but the watermark has already moved past that moment, so no
* scan returns it again — and on a read-only install the switch never
* reaches the provider at all, so nothing even triggers one. Deliberate: the
* reminder was silenced on purpose, and the event itself is back in view.
* Switch a calendar on or off the app's one visibility model, writing the
* system's `Calendars.VISIBLE`. A reminder that came due while the calendar
* was off stays gone when it is switched back on: the watermark has already
* moved past it (#75).
*/
fun setCalendarVisible(id: Long, visible: Boolean) = write {
repository.setCalendarsVisible(listOf(id), visible)
}
/**
* Switch every calendar of one account on or off — the "toggle all"
* affordance on an account header. Each row is written on its own, in one
* coroutine so the writes can't race each other.
* Switch every calendar of one account on or off. Each row is written on its
* own, in one coroutine so the writes can't race.
*/
fun setAccountVisible(ids: Collection<Long>, visible: Boolean) = write {
repository.setCalendarsVisible(ids, visible)

View File

@@ -9,13 +9,8 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
/**
* One account's calendars, as every surface that lists calendars by account
* shows them.
*
* An account is identified by name **and** type (#77). A Google account and a
* DAVx5 account can carry the same address and still be two separate accounts,
* from separate apps: merging them mixed their calendars into one group whose
* header — source logo, "manage in app", toggle-all, collapsed state — was
* derived from whichever calendar happened to sort first.
* shows them. An account is identified by name **and** type (#77): a Google and
* a DAVx5 account can share an address and still be two separate accounts.
*/
data class CalendarAccountGroup(
/** Stable identity: what makes two calendars belong to the same account. */
@@ -69,9 +64,8 @@ fun accountGroupTitle(group: CalendarAccountGroup): String =
}
/**
* The human name of the app backing [accountType] — the same app whose icon
* [SourceLogo] draws. Falls back to the raw account type, which is at least
* unique, when no installed app resolves for it.
* The human name of the app backing [accountType], falling back to the raw
* account type when no installed app resolves for it.
*/
@Composable
fun sourceAppName(accountType: String): String {

View File

@@ -0,0 +1,75 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.snap
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
/**
* A timed block's own time label, crossfaded rather than replaced — the block
* slides to its new slot, so the label shouldn't change in a single frame.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
snap()
} else {
MaterialTheme.motionScheme.fastEffectsSpec()
}
Crossfade(
targetState = label,
animationSpec = spec,
label = "block-time",
modifier = modifier,
) { text ->
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = color,
)
}
}
/** Where a timed block sits in its column, after tweening. */
@Immutable
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
/**
* A timed block's placement, tweened rather than jumped. Continuity comes from
* the caller keying each block by identity; a block composed for the first time
* starts at its target, so nothing flies in on the first frame.
*
* A pinch-zoom rewrites the hour height every pointer frame, and the gutter and
* grid lines follow it instantly — so the tween stands down for the gesture
* rather than leaving the blocks trailing the ruler they are measured against.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion() ||
LocalTimelineZoom.current.isPinching
) {
snap()
} else {
MaterialTheme.motionScheme.fastSpatialSpec()
}
val animatedX by animateDpAsState(x, spec, label = "block-x")
val animatedY by animateDpAsState(y, spec, label = "block-y")
val animatedWidth by animateDpAsState(width, spec, label = "block-width")
val animatedHeight by animateDpAsState(height, spec, label = "block-height")
return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight)
}

View File

@@ -35,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
Icon(
Icons.Filled.CalendarMonth,
contentDescription = null,
tint = eventFill(color, dark, soften),
tint = eventAccent(color, dark, soften),
modifier = Modifier.size(22.dp),
)
}

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -19,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.DrawerState
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
@@ -27,19 +29,23 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
/**
@@ -50,7 +56,8 @@ import kotlinx.datetime.LocalDate
* a jump-to-date action, the per-calendar visibility filter (M3) inline, and a
* pinned Settings row. The "View" section mirrors the top-bar switcher pill —
* tapping a view here selects it (and closes the drawer) rather than cycling.
* The host screen owns the drawer state.
* The host screen owns the drawer state; the sheet reads it only to dismiss
* itself on back.
*
* [currentDate] seeds the jump-to-date picker (the visible day/week-start/month
* anchor); [onJumpToDate] navigates the active view to the chosen day.
@@ -59,12 +66,17 @@ import kotlinx.datetime.LocalDate
fun CalendarDrawer(
currentView: CalendarView,
currentDate: LocalDate,
drawerState: DrawerState,
onSelectView: (CalendarView) -> Unit,
onJumpToDate: (LocalDate) -> Unit,
onSettings: () -> Unit,
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
) {
var showDatePicker by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
// Registered in the sheet so it takes precedence over the host's back handler.
BackHandler(enabled = drawerState.isOpen) { scope.launch { drawerState.close() } }
ModalDrawerSheet {
// The whole sidebar scrolls as one — header, views, the calendar filter
@@ -83,7 +95,7 @@ fun CalendarDrawer(
position = positionOf(index, viewOrder.size),
selected = view == currentView,
minHeight = 56.dp,
leading = { Icon(view.icon, contentDescription = null) },
leading = { DrawerLeadingIcon(view.icon) },
onClick = { onSelectView(view) },
)
}
@@ -93,7 +105,7 @@ fun CalendarDrawer(
title = stringResource(R.string.drawer_jump_to_date),
position = Position.Alone,
minHeight = 56.dp,
leading = { Icon(Icons.Filled.DateRange, contentDescription = null) },
leading = { DrawerLeadingIcon(Icons.Filled.DateRange) },
onClick = { showDatePicker = true },
)
@@ -107,7 +119,7 @@ fun CalendarDrawer(
title = stringResource(R.string.month_action_settings),
position = Position.Alone,
minHeight = 56.dp,
leading = { Icon(Icons.Filled.Settings, contentDescription = null) },
leading = { DrawerLeadingIcon(Icons.Filled.Settings) },
onClick = onSettings,
)
Spacer(Modifier.height(8.dp))
@@ -126,13 +138,27 @@ fun CalendarDrawer(
}
}
/** Leading slot for the drawer's plain icons: the same 40.dp footprint
* [CalendarColorChip] takes, so every leading glyph shares one vertical axis. */
@Composable
private fun DrawerLeadingIcon(icon: ImageVector) {
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
Icon(icon, contentDescription = null)
}
}
/** Branded header: the app-icon chip beside the app name. */
@Composable
private fun DrawerHeader() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 28.dp, top = 24.dp, bottom = 16.dp),
.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 24.dp,
bottom = 16.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
@@ -164,6 +190,11 @@ private fun DrawerSectionHeader(text: String) {
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 16.dp,
bottom = 8.dp,
),
)
}

View File

@@ -45,11 +45,9 @@ import de.jeanlucmakiola.floret.components.SelectedCheck
* each and a check on the selected one. Emits into the caller's [ColumnScope]
* (a scrolling column), so the caller owns the surrounding chrome.
*
* The list holds event *targets* only, so a calendar that is switched off,
* read-only or managed is silently absent — which reads as a missing calendar
* rather than an excluded one (#76). [onManageCalendars], when given, adds the
* footer row that names the possible reasons and opens the calendar manager,
* where each row then says which one applies.
* The list holds event *targets* only, so a switched-off, read-only or managed
* calendar is absent (#76). [onManageCalendars], when given, adds the footer row
* naming the possible reasons and opening the calendar manager.
*/
@Composable
fun ColumnScope.CalendarPickerGroups(

View File

@@ -0,0 +1,81 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalDate
/**
* Top-bar title that doubles as the jump-to-date entry point: the formatted
* [title] with a drop-down caret, opening the same [CalendarDatePickerDialog] the
* drawer uses and reporting the chosen day through [onJumpToDate].
*
* [currentDate] seeds the picker with whatever the bar is currently naming (the
* visible day, week start or month anchor).
*
* The row keeps its own 8.dp inset rather than shifting back onto the app bar's
* start alignment: M3 places the title flush against the navigation icon's
* trailing edge, and the title is hit-tested on top of it, so a negative offset
* would swallow taps meant for the menu button.
*/
@Composable
fun CalendarTitleButton(
title: String,
currentDate: LocalDate,
onJumpToDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
var showDatePicker by rememberSaveable { mutableStateOf(false) }
Row(
modifier = modifier
.heightIn(min = 48.dp)
.clip(MaterialTheme.shapes.large)
.clickable(
onClickLabel = stringResource(R.string.drawer_jump_to_date),
role = Role.Button,
) { showDatePicker = true }
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f, fill = false),
)
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (showDatePicker) {
CalendarDatePickerDialog(
initial = currentDate,
onConfirm = {
showDatePicker = false
onJumpToDate(it)
},
onDismiss = { showDatePicker = false },
)
}
}

View File

@@ -2,45 +2,53 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.domain.color.eventTone
import de.jeanlucmakiola.calendula.domain.color.inkFor
import de.jeanlucmakiola.calendula.domain.color.oklchOf
/**
* Whether calendar/event colours are softened toward theme-fitting pastels
* before display (issue #36). Provided app-wide from the "soften colours"
* setting; the default `true` keeps the historical look. When off, the raw
* provider colour is painted verbatim matching the sync source (DAVx5/CalDAV)
* and other calendar apps. Widgets live outside this composition and read the
* preference directly, then pass the flag to [eventFill] / [eventInk].
* Whether calendar/event colours are harmonised — hue kept, lightness re-pinned
* per role — before display (issue #36). Provided app-wide from the setting; the
* default `true` is the app's own look. When off, the raw provider colour is
* painted verbatim, matching the sync source (DAVx5/CalDAV) and other calendar
* apps. Widgets live outside this composition and read the preference directly,
* then pass the flag through.
*/
val LocalSoftenColors = staticCompositionLocalOf { true }
/**
* Display fill for an event chip/bar or a calendar tint: the [pastelize]d colour
* when [soften] is on, else the raw provider ARGB verbatim (forced opaque, since
* pastelize also returns an opaque colour).
* Fill for a surface that carries text on it — a week/day block, a month bar, a
* widget row, a picker swatch. Pair it with [eventInk].
*/
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
if (soften) pastelize(rawArgb, dark) else Color(rawArgb or 0xFF000000.toInt())
Color(eventTone(rawArgb, dark, soften).container)
/**
* Contrast ink (title text / glyph) for a filled chip painted with [eventFill]:
* white on a dark fill, near-black on a light one (issue #21). Applies to both
* softened and raw fills — a saturated hue (deep blue, purple, red) is
* perceptually dark even after softening pins its HSV value, so black text on it
* reads poorly. The choice is objective, not a tuned threshold: white wins only
* when it out-contrasts black against the fill, which (by the WCAG contrast
* ratio) is at relative luminance ≈ 0.18 — so mid and light colours keep
* near-black text. [alpha] carries the caller's soft emphasis.
* Colour for a mark on an ordinary app surface — a day dot, an agenda or search
* stripe, a calendar icon tint, the detail header. Same identity as [eventFill],
* pinned to contrast with the surface instead of with ink.
*/
fun eventInk(fill: Color, alpha: Float = 0.8f): Color {
val useWhite = fill.luminance() < INK_LUMINANCE_CROSSOVER
return (if (useWhite) Color.White else Color.Black).copy(alpha = alpha)
}
fun eventAccent(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
Color(eventTone(rawArgb, dark, soften).accent)
/**
* Relative-luminance crossover where white text starts to out-contrast black.
* Solving `contrast(white, L) = contrast(black, L)` on the WCAG ratio gives
* `L = sqrt(1.05 * 0.05) - 0.05 ≈ 0.179`.
* Ink for text drawn on an [eventFill]. [alpha] carries the caller's emphasis.
*
* Takes the fill rather than the raw colour so it stays correct for the raw
* (un-harmonised) path too, where the provider decides the lightness.
*/
private const val INK_LUMINANCE_CROSSOVER = 0.179f
fun eventInk(fill: Color, alpha: Float = 0.8f): Color =
Color(inkFor(oklchOf(fill.toArgbInt()).lightness)).copy(alpha = alpha)
/**
* Ink alpha for a block's secondary line (the time under the title). Held above
* a fainter value because harmonised containers are deep: the small time text
* needs to stay clear of the 4.5:1 WCAG asks of body text.
*/
const val SECONDARY_INK_ALPHA = 0.8f
private fun Color.toArgbInt(): Int =
(0xFF shl 24) or
((red * 255f).toInt().coerceIn(0, 255) shl 16) or
((green * 255f).toInt().coerceIn(0, 255) shl 8) or
(blue * 255f).toInt().coerceIn(0, 255)

View File

@@ -0,0 +1,150 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
/** How far the finger may wander during the hold — well under touch slop. */
private val PICKUP_TOLERANCE = 6.dp
/**
* Pick an event block up with a long press and drag it, without
* `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an
* ancestor consumes or the finger leaves the block, which a
* `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout
* is deliberately not consumed, so a scroll starting on top of a block survives.
*/
@Composable
fun rememberEventDragSource(
enabled: Boolean,
key: Any?,
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> Unit,
onMove: (pointerInRoot: Offset) -> Unit,
onDrop: () -> Unit,
onCancel: () -> Unit,
): Modifier = rememberDragSurface(
enabled = enabled,
key = key,
onPickUp = { _, pointerInRoot, nodeInRoot, size ->
onPickUp(pointerInRoot, nodeInRoot, size)
true
},
onMove = onMove,
onDrop = onDrop,
onCancel = onCancel,
)
/**
* The same pickup, for a surface that carries many draggable pieces rather than
* being one itself — the month grid, whose chips are covered by a full-bleed tap
* layer. [onPickUp] receives the press position local to this node and answers
* whether anything is there; false abandons the gesture.
*/
@Composable
fun rememberDragSurface(
enabled: Boolean,
key: Any?,
onPickUp: (local: Offset, pointerInRoot: Offset, nodeInRoot: Offset, size: IntSize) -> Boolean,
onMove: (pointerInRoot: Offset) -> Unit,
onDrop: () -> Unit,
onCancel: () -> Unit,
): Modifier {
if (!enabled) return Modifier
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
val tolerance = with(LocalDensity.current) { PICKUP_TOLERANCE.toPx() }
val coordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
val currentPickUp by rememberUpdatedState(onPickUp)
val currentMove by rememberUpdatedState(onMove)
val currentDrop by rememberUpdatedState(onDrop)
val currentCancel by rememberUpdatedState(onCancel)
return Modifier
.onGloballyPositioned { coordinates[0] = it }
.pointerInput(key) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val origin = down.position
val heldStill = withTimeoutOrNull(holdMillis) {
while (true) {
val main = awaitPointerEvent()
val change = main.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) break
if (main.changes.count { it.pressed } > 1) break
if ((change.position - origin).getDistance() > tolerance) break
// An ancestor's claim only becomes visible once the whole
// main pass has run, so look again on the final pass.
val final = awaitPointerEvent(PointerEventPass.Final)
if (final.changes.any { it.isConsumed }) break
}
} == null
if (!heldStill) return@awaitEachGesture
val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture
val took = currentPickUp(
down.position,
layout.localToRoot(down.position),
layout.positionInRoot(),
layout.size,
)
if (!took) return@awaitEachGesture
var dropped = false
try {
// Driven on the initial pass, which runs parent → child: an
// ancestor that outranks us (the pinch) has already consumed
// by the time we look, and descendants — the month grid's
// full-bleed tap layer — see our consumption.
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (change.isConsumed) break
if (!change.pressed) {
change.consume()
dropped = true
break
}
if (event.changes.count { it.pressed } > 1) break
change.consume()
coordinates[0]?.takeIf { it.isAttached }
?.let { currentMove(it.localToRoot(change.position)) }
}
} finally {
// Also reached when the pointer node is disposed mid-drag —
// a cancel, not a drop, and must never write.
if (dropped) currentDrop() else currentCancel()
}
}
}
}
/** A lift on pickup, then a tick every time the drop target snaps to a new slot. */
@Composable
fun DragSnapHaptics(slot: Any?) {
val haptics = LocalHapticFeedback.current
val previous = remember { arrayOfNulls<Any>(1) }
LaunchedEffect(slot) {
val had = previous[0]
previous[0] = slot
when {
slot == null -> Unit
had == null -> haptics.performHapticFeedback(HapticFeedbackType.LongPress)
had != slot -> haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
}
}

View File

@@ -0,0 +1,144 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.SnackChip
import de.jeanlucmakiola.floret.components.SnackChipHeight
import de.jeanlucmakiola.floret.components.SnackChipMargin
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.coroutines.delay
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.Locale
/** How long the confirmation chip stays up, matching a short snackbar. */
private const val CHIP_MILLIS = 4_000L
/** How long an *undone* move stays up — shorter, since it offers nothing to act on. */
private const val UNDONE_CHIP_MILLIS = 1_600L
/** What the chip currently reads, kept past the outcome it was built from. */
private data class ChipContent(val message: String, val undo: MoveUndo?)
/** The FAB's own band at the bottom end, which the chip must not run into. */
private val FAB_BAND = 88.dp
/**
* The two surfaces a drag-and-drop reschedule needs on top of the calendar: the
* recurring-scope prompt, and the confirmation chip carrying Undo. None of the
* calendar screens sets a `snackbarHost`, so this hosts its own confirmation as
* a pill on the FAB's band, leaving the calendar visible behind it.
*/
@Composable
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) {
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle()
val writeInFlight by viewModel.inFlight.collectAsStateWithLifecycle()
val locale = currentLocale()
val use24Hour = LocalUse24HourFormat.current
prompt?.let { pending ->
RecurringScopeDialog(
title = stringResource(R.string.event_move_recurring_title),
onSelect = viewModel::moveWithScope,
onDismiss = viewModel::cancelScope,
allowSeries = !pending.occurrenceOnly,
reason = stringResource(R.string.event_move_occurrence_only)
.takeIf { pending.occurrenceOnly },
)
}
val moved = outcome as? MoveOutcome.Moved
val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) }
val message = when (outcome) {
null -> null
is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty())
MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end)
MoveOutcome.Failed -> stringResource(R.string.event_move_failed)
}
// Held past the outcome being consumed so the chip has something to draw
// while it springs back out. Updated in composition rather than from an
// effect, which would land a frame late and open the chip on stale text.
val shown = remember { mutableStateOf(ChipContent("", null)) }
if (message != null && (shown.value.message != message || shown.value.undo != moved?.undo)) {
shown.value = ChipContent(message, moved?.undo)
}
val content = shown.value
// Restarted by the undo tick and held while a write is in flight: an undo
// tapped in the last moments of the window leaves the outcome at Moved on
// purpose, and this timer would otherwise fire mid-undo and close the chip
// just before the same chip has to say "undone".
LaunchedEffect(outcome, undoTick, writeInFlight) {
if (outcome == null || writeInFlight) return@LaunchedEffect
delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS)
viewModel.consumeOutcome()
}
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val chipMaxWidth = maxWidth - FAB_BAND
// A FAB-height band at the bottom start with the FAB's own margin, so
// the chip lines up beside the bottom-end FAB.
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.navigationBarsPadding()
.padding(start = SnackChipMargin, bottom = SnackChipMargin)
.height(SnackChipHeight),
contentAlignment = Alignment.CenterStart,
) {
// The action goes away while a write runs: undo() refuses a second
// write anyway, so offering it would be a button that does nothing.
val undo = content.undo?.takeIf { !writeInFlight }
SnackChip(
visible = outcome != null,
message = content.message,
maxWidth = chipMaxWidth,
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
onAction = undo?.let { { viewModel.undo(it) } },
)
}
}
}
/**
* "Fri, 7 Aug, 09:00" — the day, plus the time for a timed event. All-day events
* are read back on the UTC calendar day they are anchored to (#65, #82).
*/
private fun formatMovedTo(
startMillis: Long,
isAllDay: Boolean,
use24Hour: Boolean,
locale: Locale,
): String {
val zone: ZoneId = if (isAllDay) ZoneOffset.UTC else ZoneId.systemDefault()
val skeleton = when {
isAllDay -> "EEEdMMM"
use24Hour -> "EEEdMMMHm"
else -> "EEEdMMMhm"
}
return localizedDateFormatter(locale, skeleton)
.format(Instant.ofEpochMilli(startMillis).atZone(zone))
}

View File

@@ -0,0 +1,126 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by
* whichever event block is being composed. Null means moving is off entirely and
* blocks register no drag gesture at all.
*/
@Immutable
class EventMoveScope(
/**
* Calendars whose events may be moved. Nothing below the UI enforces this —
* the repository writes whatever it is handed — so this gate is load-bearing.
*/
val movableCalendarIds: Set<Long>,
/**
* Whether the *gesture* is on (#173). Off gates the drag alone: [edit] and the
* TalkBack action below stay, since invoking a menu item can't cause the
* accidental drags the setting exists to stop.
*/
val dragEnabled: Boolean,
/** False when the drop was refused outright, so nothing will be written. */
val move: (MoveRequest) -> Boolean,
/**
* True while a dropped event is being written, including the time its scope
* dialog is up. A flow rather than a value so this scope stays the same
* object across a move — every visible block reads it as a composition local.
*/
val inFlight: StateFlow<Boolean>,
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
val undoStarted: StateFlow<Int>,
/** Open an event in the edit form — the pointer-free route to the same change. */
val edit: (EventInstance) -> Unit,
) {
fun allows(event: EventInstance): Boolean = event.calendarId in movableCalendarIds
}
val LocalEventMove = compositionLocalOf<EventMoveScope?> { null }
private val NEVER_IN_FLIGHT = MutableStateFlow(false)
private val NEVER_UNDONE = MutableStateFlow(0)
/**
* Whether a dropped event is still being written — false wherever moving is off.
* The drag overlays hold a landed block on its target for this window.
*/
@Composable
fun moveInFlight(): Boolean {
val flow = LocalEventMove.current?.inFlight ?: NEVER_IN_FLIGHT
return flow.collectAsStateWithLifecycle().value
}
/**
* Runs [onUndo] when an undo write begins, and never for one that began before
* this composable came on screen.
*/
@Composable
fun OnUndoStarted(onUndo: () -> Unit) {
val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE
val tick by flow.collectAsStateWithLifecycle()
var seen by remember { mutableIntStateOf(tick) }
LaunchedEffect(tick) {
if (tick == seen) return@LaunchedEffect
seen = tick
onUndo()
}
}
/** Opacity the source block keeps while its floating copy travels. */
const val GHOST_ALPHA: Float = 0.3f
/**
* Opacity for a block whose copy is in flight: ghosted from the lift until the
* copy is handed back, then animated up rather than switched, so the block
* appears to travel to its new slot instead of vanishing and reappearing.
*/
@Composable
fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState(
targetValue = if (lifted) GHOST_ALPHA else 1f,
label = "ghost-alpha",
).value
/**
* Whether [event] can be picked up and dragged — its calendar allows moving and
* the gesture is switched on. Not the same question as [eventMoveAction], which
* survives the setting.
*/
@Composable
fun eventDragAllowed(event: EventInstance): Boolean {
val move = LocalEventMove.current ?: return false
return move.dragEnabled && move.allows(event)
}
/**
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't
* pointer-only. Null when this event can't be moved.
*/
@Composable
fun eventMoveAction(event: EventInstance): CustomAccessibilityAction? {
val move = LocalEventMove.current ?: return null
if (!move.allows(event)) return null
val label = stringResource(R.string.event_move_action)
return remember(event.instanceId, label, move) {
CustomAccessibilityAction(label) {
move.edit(event)
true
}
}
}

View File

@@ -0,0 +1,127 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.locale.currentLocale
/** Width of the hour gutter down the start edge of the day and week timelines. */
val GUTTER_WIDTH = 48.dp
/**
* Start inset for the gutter's content (week badge + hour labels) so it centres
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp,
* matching the icon button's centre.
*/
val GUTTER_CONTENT_START_INSET = 8.dp
/**
* End inset for everything that lines up with the day columns — the week's day
* header, the all-day strip and the timeline itself. Without it the last column
* runs flush into the screen edge while the gutter gives the other side room
* (#192); 8dp mirrors [GUTTER_CONTENT_START_INSET].
*/
val TIMELINE_CONTENT_END_INSET = 8.dp
private val BADGE_HEIGHT = 20.dp
/** How far the fixed hour labels recede while a block is being dragged. */
private const val DIMMED_HOUR_ALPHA = 0.3f
/**
* The timeline's hour gutter. Scrolls in sync with the day columns through the
* shared [scrollState], and while [dragController] holds a lifted block it dims
* the hour labels and floats a badge with the drag's current start time at the
* row the block would land on.
*/
@Composable
fun HourGutter(
scrollState: ScrollState,
hourHeight: Dp,
dragController: TimelineDragController,
modifier: Modifier = Modifier,
) {
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
// Derived: the drag is rewritten every frame, its snapped start once a slot.
val dragStartMin by remember(dragController) {
derivedStateOf { dragController.drag?.startMin }
}
val hourAlpha by animateFloatAsState(
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
label = "hourLabelAlpha",
)
Box(
modifier = modifier
.width(GUTTER_WIDTH)
.padding(start = GUTTER_CONTENT_START_INSET)
.fillMaxHeight()
.verticalScroll(scrollState),
) {
Column {
(0 until 24).forEach { h ->
Box(
modifier = Modifier
.fillMaxWidth()
.height(hourHeight),
) {
if (h > 0) {
Text(
text = formatHourLabel(h, use24Hour, locale),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
.copy(alpha = hourAlpha),
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = (-6).dp),
)
}
}
}
}
dragStartMin?.let { startMin ->
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = top)
.height(BADGE_HEIGHT)
.background(MaterialTheme.colorScheme.primary, CircleShape)
.padding(horizontal = 4.dp)
// The dragged block already announces this time.
.clearAndSetSemantics { },
contentAlignment = Alignment.Center,
) {
Text(
text = formatGutterTime(startMin, use24Hour, locale),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
maxLines = 1,
softWrap = false,
)
}
}
}
}

View File

@@ -1,6 +1,10 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import kotlin.time.Instant
/**
@@ -14,3 +18,30 @@ val LocalDimCutoff = compositionLocalOf<Instant?> { null }
/** Opacity applied to a completed/past event chip when it is dimmed. */
const val EventDimAlpha = 0.4f
/**
* How a declined invitation's title is struck through (#180) — the mark every
* surface uses to say "you answered no", chosen over hiding the event because it
* is still something the organiser expects you at. Null for everything else, so
* it drops straight into a `Text`'s `textDecoration`.
*/
fun declinedDecoration(isDeclined: Boolean): TextDecoration? =
if (isDeclined) TextDecoration.LineThrough else null
/** [declinedDecoration] for the rows that take styled text rather than a `String`. */
fun declinedTitle(title: String, isDeclined: Boolean): AnnotatedString = if (isDeclined) {
AnnotatedString(title, SpanStyle(textDecoration = TextDecoration.LineThrough))
} else {
AnnotatedString(title)
}
/** [declinedTitle] over already-styled text, e.g. a search hit's marked runs. */
fun declinedTitle(title: AnnotatedString, isDeclined: Boolean): AnnotatedString =
if (!isDeclined) {
title
} else {
buildAnnotatedString {
append(title)
addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), 0, title.length)
}
}

View File

@@ -59,10 +59,9 @@ import kotlin.time.Clock
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
* back.
*
* [leadTimeSummary] adds a second line to each lead-time row. It exists for the
* all-day pickers, where the lead time alone doesn't say when anything happens:
* the hour comes from the separate "show all-day reminders at" setting, so
* "1 day before" is only half the answer until the row spells out the time.
* [leadTimeSummary] adds a second line to each lead-time row, for the all-day
* pickers: the hour comes from the separate "show all-day reminders at" setting,
* so the lead time alone doesn't say when anything happens.
*/
@Composable
fun ReminderDefaultPicker(
@@ -233,10 +232,8 @@ internal fun PickerDescription(text: String) {
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
*
* Every option carries the dates it resolves to today as its summary, computed
* through the same [dayCount] the agenda windows by (hence [weekStart], which
* "This week" depends on). Without it the two groups are near-indistinguishable
* — "This week" and "Next 7 days" name different windows in the same words, and
* only the concrete span tells them apart.
* through the same [dayCount] the agenda windows by (hence [weekStart]) — only
* the concrete span tells "This week" and "Next 7 days" apart.
*/
@Composable
fun AgendaRangePicker(
@@ -260,11 +257,11 @@ fun AgendaRangePicker(
val locale = currentLocale()
val zone = remember { TimeZone.currentSystemDefault() }
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
// The window each option would open right now — the agenda's own end-day
// arithmetic (anchor + dayCount - 1), so the dates match what it will show.
// The agenda's own end-day arithmetic (anchor + dayCount - 1), so the dates
// match what it will show.
val windowSummary: (AgendaRange) -> String = { range ->
val end = today.plus(range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY)
agendaRangeWindowSummary(range, today, end, locale)
agendaRangeWindowSummary(range, today, end, locale, monthAsSpan = true)
}
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
@@ -306,8 +303,8 @@ fun AgendaRangePicker(
} else {
stringResource(R.string.agenda_range_custom)
},
// Only a chosen custom window has dates to show; an unset one has no
// day count yet, so the row stays single-line until it does.
// An unset custom window has no day count yet, so the row stays
// single-line until it does.
summary = if (customSelected) windowSummary(selected) else null,
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
selected = customSelected,
@@ -357,35 +354,66 @@ private fun CustomDaysEditor(
}
/**
* Snooze-duration picker, full-screen and **single-select**: the [presets]
* (whole-minute delays) each sit as a checkmark row, with a "Custom" row that
* expands an inline amount field plus a Minutes/Hours unit toggle to enter an
* arbitrary delay. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking
* a preset or confirming a custom value applies via [onSelect] and closes.
* [label] renders a delay in minutes as a duration ("10 minutes", "1 hour") and
* is reused for both the rows and the custom preview.
* Duration picker, full-screen and **single-select**: the [presets] (whole-minute
* lengths) each sit as a checkmark row, with a "Custom" row that expands an
* inline amount field plus a Minutes/Hours unit toggle to enter an arbitrary
* one. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking a preset or
* confirming a custom value applies via [onSelect] and closes. [label] renders a
* length in minutes ("10 minutes", "1 hour") and is reused for both the rows and
* the custom preview.
*
* [inheritLabel] adds an exclusive "use the default" row on top, for the
* per-calendar pickers; picking it reports null. [selected] is null exactly when
* that row is the current choice. [description] explains the setting above the
* rows when it needs it. [maxMinutes] caps what the custom editor will confirm,
* for settings that clamp their stored value — offering a length the store would
* silently shorten is worse than refusing it.
*/
@Composable
fun SnoozeDurationPicker(
fun DurationPicker(
title: String,
presets: List<Int>,
selected: Int,
selected: Int?,
label: @Composable (Int) -> String,
onSelect: (Int) -> Unit,
onSelect: (Int?) -> Unit,
onDismiss: () -> Unit,
description: String? = null,
inheritLabel: String? = null,
maxMinutes: Int = Int.MAX_VALUE,
) {
val customSelected = selected !in presets
val inherits = selected == null
// The current choice when it isn't one of the presets — the Custom row then
// names it and the editor opens pre-filled with it.
val custom = selected?.takeIf { it !in presets }
val rowCount = presets.size + 1 // + the custom row
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable {
mutableStateOf(if (customSelected) snoozeCustomAmount(selected).toString() else "")
mutableStateOf(custom?.let { durationCustomAmount(it).toString() }.orEmpty())
}
var unit by rememberSaveable {
mutableStateOf(if (customSelected) snoozeCustomUnit(selected) else ReminderUnit.Minutes)
mutableStateOf(custom?.let { durationCustomUnit(it) } ?: ReminderUnit.Minutes)
}
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
if (description != null) PickerDescription(description)
if (inheritLabel != null) {
GroupedRow(
title = inheritLabel,
position = Position.Alone,
selected = inherits,
trailing = if (inherits) {
{ SelectedCheck() }
} else {
null
},
onClick = {
onSelect(null)
onDismiss()
},
)
Spacer(Modifier.height(24.dp))
}
presets.forEachIndexed { index, minute ->
val isSelected = minute == selected
GroupedRow(
@@ -406,10 +434,10 @@ fun SnoozeDurationPicker(
// The Custom row connects downward into the editor card when expanded, so
// the two read as one grouped container (the shared custom-expand pattern).
GroupedRow(
title = if (customSelected) label(selected) else stringResource(R.string.event_edit_reminder_custom),
title = custom?.let { label(it) } ?: stringResource(R.string.event_edit_reminder_custom),
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
selected = customSelected,
trailing = if (customSelected) {
selected = custom != null,
trailing = if (custom != null) {
{ SelectedCheck() }
} else {
null
@@ -421,12 +449,13 @@ fun SnoozeDurationPicker(
enter = expandEnter(),
exit = collapseExit(),
) {
CustomSnoozeEditor(
CustomDurationEditor(
amountText = amountText,
onAmountChange = { amountText = it },
unit = unit,
onUnitChange = { unit = it },
label = label,
maxMinutes = maxMinutes,
onConfirm = { minutes ->
onSelect(minutes)
onDismiss()
@@ -436,41 +465,48 @@ fun SnoozeDurationPicker(
}
}
/** Whole hours if the delay divides evenly, else minutes. */
private fun snoozeCustomUnit(minutes: Int): ReminderUnit =
/** Whole hours if the length divides evenly, else minutes. */
private fun durationCustomUnit(minutes: Int): ReminderUnit =
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
private fun snoozeCustomAmount(minutes: Int): Int =
private fun durationCustomAmount(minutes: Int): Int =
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
/**
* The expanded "Custom" snooze editor: a tonal card connected to the Custom row
* The expanded "Custom" duration editor: a tonal card connected to the Custom row
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
* the delay it resolves to, and a tonal confirm enabled only for a valid 1999
* amount. [onConfirm] receives the final delay in minutes.
* the length it resolves to, and a tonal confirm enabled only for a valid 1999
* amount that stays within [maxMinutes]. Over the cap, the preview names the
* limit instead of a length the setting could not keep. [onConfirm] receives the
* final length in minutes.
*/
@Composable
private fun CustomSnoozeEditor(
private fun CustomDurationEditor(
amountText: String,
onAmountChange: (String) -> Unit,
unit: ReminderUnit,
onUnitChange: (ReminderUnit) -> Unit,
label: @Composable (Int) -> String,
maxMinutes: Int,
onConfirm: (Int) -> Unit,
) {
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
val entered = amountText.toIntOrNull()?.takeIf { it in 1..999 }?.times(unit.minutesFactor)
val amount = entered?.takeIf { it <= maxMinutes }
CustomAmountEditor(
amountText = amountText,
onAmountChange = onAmountChange,
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
selectedUnit = units.indexOf(unit).coerceAtLeast(0),
onUnitChange = { onUnitChange(units[it]) },
preview = amount?.let { label(it * unit.minutesFactor) }
?: stringResource(R.string.reminder_custom_amount),
preview = when {
amount != null -> label(amount)
entered != null -> stringResource(R.string.duration_custom_max, label(maxMinutes))
else -> stringResource(R.string.reminder_custom_amount)
},
setLabel = stringResource(R.string.reminder_custom_set),
confirmEnabled = amount != null,
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
onConfirm = { amount?.let(onConfirm) },
)
}

View File

@@ -0,0 +1,169 @@
package de.jeanlucmakiola.calendula.ui.common
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/**
* Stand-in events for the view previews. A preview has no business querying the
* provider for a thumbnail, so every chooser renders the same invented week.
*
* Colours are raw ARGB on purpose: that is what the provider hands out for an
* event, so a theme token here would misrepresent what the views actually draw.
*/
private val SAMPLE_COLORS = listOf(
0xFF3F7BD4.toInt(),
0xFFCE5B4C.toInt(),
0xFF4E9A6A.toInt(),
0xFF8A63C7.toInt(),
)
private val SAMPLE_TITLES = listOf(
"Standup",
"Lunch",
"Review",
"Gym",
"Call",
"Workshop",
"Dentist",
"Trip",
)
/** Hands out [EventInstance]s with running ids and cycling sample titles/colours. */
private class SampleEvents(private val zone: TimeZone) {
private var id = 0L
fun timed(
day: LocalDate,
hour: Int,
minute: Int = 0,
lengthMinutes: Int,
colorIndex: Int,
): EventInstance {
val start = day.atTime(hour, minute).toInstant(zone)
return build(
start = start,
end = start + lengthMinutes.minutes,
isAllDay = false,
colorIndex = colorIndex,
)
}
fun allDay(from: LocalDate, days: Int, colorIndex: Int): EventInstance = build(
// All-day events sit at UTC midnights with an exclusive end.
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
isAllDay = true,
colorIndex = colorIndex,
)
private fun build(
start: Instant,
end: Instant,
isAllDay: Boolean,
colorIndex: Int,
): EventInstance {
val next = ++id
return EventInstance(
instanceId = next,
eventId = next,
calendarId = 1L,
title = SAMPLE_TITLES[(next.toInt() - 1) % SAMPLE_TITLES.size],
start = start,
end = end,
isAllDay = isAllDay,
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
location = null,
)
}
}
/**
* A month's worth of stand-ins, spread so most weeks carry something, with one
* multi-day bar bridging cells and a busy today for the split pane.
*/
internal fun sampleMonthEvents(
firstOfMonth: LocalDate,
today: LocalDate,
zone: TimeZone,
): List<EventInstance> {
val sample = SampleEvents(zone)
return buildList {
add(sample.allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
add(sample.timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, lengthMinutes = 60, colorIndex = 0))
add(sample.timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, lengthMinutes = 120, colorIndex = 1))
add(sample.timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, lengthMinutes = 60, colorIndex = 3))
add(sample.timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, lengthMinutes = 60, colorIndex = 0))
add(sample.timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, lengthMinutes = 60, colorIndex = 2))
add(sample.timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, lengthMinutes = 120, colorIndex = 1))
add(sample.timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, lengthMinutes = 60, colorIndex = 3))
// Today, so the split pane's list and the grid's dots both have content.
add(sample.timed(today, 9, lengthMinutes = 60, colorIndex = 0))
add(sample.timed(today, 12, lengthMinutes = 60, colorIndex = 1))
add(sample.timed(today, 15, lengthMinutes = 120, colorIndex = 3))
}
}
/**
* Stand-ins for a timeline preview over [days]. [anchor] — the day the preview
* is centred on — gets the busy set, including one overlapping pair so the
* side-by-side lane layout shows; the rest of the week is sparser. A multi-day
* all-day bar is only added when there is more than one column to span.
*/
internal fun sampleTimelineEvents(
days: List<LocalDate>,
anchor: LocalDate,
zone: TimeZone,
): List<EventInstance> {
val sample = SampleEvents(zone)
return buildList {
if (days.size > 1) {
add(sample.allDay(days[days.size / 2], days = 2, colorIndex = 2))
} else {
add(sample.allDay(anchor, days = 1, colorIndex = 2))
}
// The anchor day: enough to fill the column, with 09:3011:00 running
// under 09:0010:00 so the overlap resolves into two lanes.
add(sample.timed(anchor, 9, lengthMinutes = 60, colorIndex = 0))
add(sample.timed(anchor, 9, minute = 30, lengthMinutes = 90, colorIndex = 1))
add(sample.timed(anchor, 13, lengthMinutes = 120, colorIndex = 3))
days.filterNot { it == anchor }.forEachIndexed { index, day ->
// Skip every third day so the week doesn't read as a solid block.
if (index % 3 == 2) return@forEachIndexed
add(
sample.timed(
day = day,
hour = 8 + (index * 3) % 9,
lengthMinutes = if (index % 2 == 0) 60 else 90,
colorIndex = index,
),
)
}
}
}
/**
* Stand-ins for the agenda preview: a forward-looking fortnight where the first
* days are busy and later ones thin out, so the grouped-by-day list shows both
* a full day and the gaps between days.
*/
internal fun sampleAgendaEvents(today: LocalDate, zone: TimeZone): List<EventInstance> {
val sample = SampleEvents(zone)
return buildList {
add(sample.timed(today, 9, lengthMinutes = 60, colorIndex = 0))
add(sample.timed(today, 12, minute = 30, lengthMinutes = 60, colorIndex = 1))
add(sample.timed(today, 16, lengthMinutes = 90, colorIndex = 3))
val tomorrow = today.plus(1, DateTimeUnit.DAY)
add(sample.allDay(tomorrow, days = 2, colorIndex = 2))
add(sample.timed(tomorrow, 10, lengthMinutes = 120, colorIndex = 1))
add(sample.timed(today.plus(3, DateTimeUnit.DAY), 14, lengthMinutes = 60, colorIndex = 0))
add(sample.timed(today.plus(6, DateTimeUnit.DAY), 11, lengthMinutes = 60, colorIndex = 3))
add(sample.timed(today.plus(10, DateTimeUnit.DAY), 18, lengthMinutes = 120, colorIndex = 2))
}
}

View File

@@ -105,29 +105,19 @@ fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
/**
* The rule's first few dates as one line — "Next: 30 Jul, 6 Aug, 13 Aug" — for
* the recurrence picker.
*
* The phrase [recurrenceText] renders says what the rule *is*; this says what
* it *does*, which is the part a monthly rule on the 31st or an every-other-week
* rule with weekday picks gets wrong in people's heads. It expands the rule
* with [upcomingOccurrences], so it inherits that function's RFC reading — and
* its limits: only the shapes the picker itself can build.
*
* A rule that yields nothing at all (an end date before the start) says so
* rather than showing an empty list, since that is a mistake worth catching
* before saving.
* the recurrence picker, where [recurrenceText]'s phrase says what the rule is
* and this says what it does. Expands via [upcomingOccurrences] and inherits its
* limits. A rule that yields nothing says so rather than showing an empty list.
*/
@Composable
fun nextOccurrencesText(
rule: SimpleRecurrence,
// Spelled out: in this file, the bare LocalDate is java.time's.
start: kotlinx.datetime.LocalDate,
locale: Locale,
): String {
val dates = rule.upcomingOccurrences(start, limit = NEXT_OCCURRENCE_COUNT)
if (dates.isEmpty()) return stringResource(R.string.event_edit_recurrence_next_none)
// Years only once they carry information — a yearly rule is otherwise the
// same date repeated, and a monthly one crossing New Year hides that it did.
// Years only once they carry information (see [spansMultipleYears]).
val pattern = if (occurrencesSpanYears(dates, start)) "dMMMy" else "dMMM"
val formatter = localizedDateFormatter(locale, pattern)
val formatted = dates.map { formatter.format(it.toJavaLocalDate()) }

View File

@@ -0,0 +1,69 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.floret.components.OptionCard
/**
* How far a write to a recurring event should reach: this occurrence, it and
* everything after (a series split), or the whole series. Shared by the edit
* screen's save and a drag-and-drop reschedule; one of the two carve-outs from
* the full-screen picker rule.
*
* [allowOccurrence] drops "only this event" (an exception row can't carry its own
* rule); [allowSeries] drops the two wider options, for a rule whose days can't
* be recalculated from one moved occurrence — [reason] then says why.
*/
@Composable
fun RecurringScopeDialog(
title: String,
onSelect: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
allowOccurrence: Boolean = true,
allowSeries: Boolean = true,
reason: String? = null,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (reason != null) {
Text(
text = reason,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (allowOccurrence) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
)
}
if (allowSeries) {
OptionCard(
label = stringResource(R.string.event_delete_option_following),
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { onSelect(RecurringWriteScope.AllEvents) },
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}

View File

@@ -37,3 +37,21 @@ fun reminderLeadTimeLabel(minutes: Int): String = when {
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
}
/**
* Humanise a plain duration — no "before": "45 minutes", "8 hours", or both
* parts for a mixed length ("1 hour 30 minutes"). Shared by the snooze delay and
* the default event duration (#54).
*/
@Composable
fun durationLabel(minutes: Int): String {
val hours = minutes / 60
val rest = minutes % 60
val hoursLabel = pluralStringResource(R.plurals.duration_hours, hours, hours)
val minutesLabel = pluralStringResource(R.plurals.duration_minutes, rest, rest)
return when {
hours == 0 -> minutesLabel
rest == 0 -> hoursLabel
else -> stringResource(R.string.duration_hours_minutes, hoursLabel, minutesLabel)
}
}

View File

@@ -0,0 +1,463 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormProblem
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.allowsEventMove
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.realignRecurrence
import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.shiftedByDays
import de.jeanlucmakiola.calendula.domain.shiftedTo
import de.jeanlucmakiola.calendula.domain.toEditForm
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Instant
import javax.inject.Inject
private const val MILLIS_PER_MINUTE = 60_000
private const val MINUTES_PER_DAY = 24 * 60
/** Where a dragged event should land. */
sealed interface MoveTarget {
/** A new start instant — a timeline drag, which moves time and day at once. */
data class Start(val instant: Instant) : MoveTarget
/**
* A whole-day shift, keeping the time of day — a month-grid or all-day drag.
* A delta rather than a target date, so the screen and this view model can't
* disagree by a day over which zone the event's first day is resolved in.
*/
data class ByDays(val days: Int) : MoveTarget
}
/**
* One dropped event. [beginMillis]/[endMillis] are the dragged *occurrence's*
* own times (`Instances.BEGIN`/`END`), as the detail and edit screens pass them.
*/
data class MoveRequest(
val eventId: Long,
val beginMillis: Long,
val endMillis: Long,
val target: MoveTarget,
)
/**
* A recurring drop waiting for the user to pick how far it reaches.
* [occurrenceOnly] means a wider write would leave the rule and the series
* anchor disagreeing — either the rule names days one moved occurrence can't
* re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor
* across the same midnights the occurrence crossed.
*/
data class MoveScopePrompt(val occurrenceOnly: Boolean)
/** The inverse of a completed move, for the undo action. */
data class MoveUndo(
val eventId: Long,
/** The form as it now stands (the moved state). */
val moved: EventForm,
/** The form as it stood before the move. */
val restored: EventForm,
)
sealed interface MoveOutcome {
/**
* Written. [startMillis] is where the dragged occurrence now begins, for the
* confirmation message; [undo] is null when the write has no clean inverse.
*/
data class Moved(
val startMillis: Long,
val isAllDay: Boolean,
val undo: MoveUndo?,
) : MoveOutcome
data object Undone : MoveOutcome
/** `WRITE_CALENDAR` was revoked between the drop and the provider call. */
data object WriteDenied : MoveOutcome
/** The event vanished (sync, another device) between the read and the write. */
data object Gone : MoveOutcome
/** The drop would push the series past its own `UNTIL`, generating nothing. */
data object BlockedSeriesEnd : MoveOutcome
data object Failed : MoveOutcome
}
/**
* Writes a drag-and-drop reschedule (#68) through the same repository calls the
* edit screen's save uses, so recurring writes, reminder reconciliation and
* attendee preservation behave identically. Hosted at `CalendarHost` so one
* instance serves every calendar view and survives view switches. The full
* prefilled form is carried through the write — a stripped one would wipe the
* reminders and attendees the occurrence-exception path reconciles.
*/
@HiltViewModel
class RescheduleViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null)
val scopePrompt: StateFlow<MoveScopePrompt?> = _scopePrompt.asStateFlow()
private val _outcome = MutableStateFlow<MoveOutcome?>(null)
val outcome: StateFlow<MoveOutcome?> = _outcome.asStateFlow()
private var pending: PreparedMove? = null
private val _inFlight = MutableStateFlow(false)
/**
* True from the moment a drop is accepted until its write settles — the
* window the dropped block holds its landing position for.
*/
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
private val _undoStarted = MutableStateFlow(0)
/**
* Ticks the moment an undo write begins, before the provider has anything to
* re-read, so the view that drew the drop can carry its chip back rather than
* let it reappear on the old day.
*/
val undoStarted: StateFlow<Int> = _undoStarted.asStateFlow()
/**
* Set from the moment a drop is accepted until its write settles. Two drops
* of the same recurring event inside that window would compute their shift
* from the same pre-move occurrence, and the shifts would compound.
*/
private var busy = false
set(value) {
field = value
_inFlight.value = value
}
/**
* The calendars whose events may be dragged. Nothing below the UI guards
* this, so a block outside this set registers no drag gesture at all.
*/
val movableCalendarIds: StateFlow<Set<Long>> = repository.calendars()
.map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() }
.catch { emit(emptySet<Long>()) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(),
)
/** Everything a drop needs, resolved before anything is written. */
private data class PreparedMove(
val request: MoveRequest,
val original: EventForm,
val updated: EventForm,
/** True for a series master with a rule — an exception row is not one. */
val isRecurring: Boolean,
/** False when the rule names days this move can't re-derive. */
val canRealign: Boolean,
/** The series row's DTSTART date after the move — what its `UNTIL` must clear. */
val newAnchorDate: LocalDate,
)
/**
* Take a drop, unless one is already being written. False means nothing will
* be written and the caller must let its held copy go now — waiting on
* [inFlight] would strand it until the settle timeout instead.
*/
fun move(request: MoveRequest): Boolean {
if (busy || _scopePrompt.value != null) return false
busy = true
viewModelScope.launch {
val prepared = prepare(request)
if (prepared == null) {
busy = false
return@launch
}
if (!prepared.isRecurring) {
write(prepared, RecurringWriteScope.AllEvents)
busy = false
} else {
// Still busy: the scope dialog is now the thing in flight.
pending = prepared
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
}
}
return true
}
/** Answer the scope dialog. */
fun moveWithScope(scope: RecurringWriteScope) {
val prepared = pending ?: return
// Guard against the dialog ever offering a scope the rule can't carry.
if (!prepared.canRealign && scope != RecurringWriteScope.ThisEvent) return
pending = null
_scopePrompt.value = null
viewModelScope.launch {
write(prepared, scope)
busy = false
}
}
/** Dismiss the scope dialog without writing. */
fun cancelScope() {
pending = null
busy = false
_scopePrompt.value = null
}
/**
* Put a completed move back where it came from. The outcome stands until the
* inverse write reports back, so the one chip changes what it says rather
* than closing and reopening. False when another write is already running —
* the chip hides its action for that window, so this is the backstop.
*/
fun undo(undo: MoveUndo): Boolean {
if (busy) return false
busy = true
_undoStarted.value += 1
viewModelScope.launch {
_outcome.value = try {
repository.updateEvent(undo.eventId, undo.moved, undo.restored)
MoveOutcome.Undone
} catch (e: CancellationException) {
throw e
} catch (e: SecurityException) {
MoveOutcome.WriteDenied
} catch (e: NoSuchEventException) {
MoveOutcome.Gone
} catch (e: Exception) {
MoveOutcome.Failed
}
busy = false
}
return true
}
/** Clear the outcome once the screen has shown it. */
fun consumeOutcome() {
_outcome.value = null
}
private suspend fun prepare(request: MoveRequest): PreparedMove? {
val detail = try {
repository.eventDetail(request.eventId)
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
_outcome.value = MoveOutcome.Gone
return null
} catch (e: Exception) {
_outcome.value = MoveOutcome.Failed
return null
}
val zone = TimeZone.currentSystemDefault()
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
val shifted = when (val target = request.target) {
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
}
// A zero-distance drop is not a write.
if (shifted == original) return null
// The UNTIL check is deferred to the write, which knows how far the move
// reaches and so which date has to clear it.
if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
_outcome.value = MoveOutcome.Failed
return null
}
// An exception row stands alone whatever rule a sync adapter left on it.
val isRecurring = original.rrule != null && !detail.isException
val movedDay = shifted.start.date != original.start.date
// The series anchor moves by the same *wall-clock* shift as the dragged
// occurrence, so only a whole-day shift carries it across the same number
// of midnights whatever time of day it sits at. A same-date drag has its
// own, narrower check — see anchorKeepsItsDay.
val wholeDayShift = original.isAllDay || shifted.start.time == original.start.time
val realigned = if (isRecurring && movedDay) {
if (wholeDayShift) {
realignRecurrence(
requireNotNull(original.rrule),
original.start.date,
shifted.start.date,
)
} else {
null
}
} else {
original.rrule
}
return PreparedMove(
request = request,
original = original,
// A rule we can't re-derive stays verbatim: the only scope offered
// then is the single occurrence, whose exception row carries no rule.
updated = shifted.copy(rrule = realigned ?: original.rrule),
isRecurring = isRecurring,
canRealign = when {
!isRecurring -> true
movedDay -> realigned != null
else -> anchorKeepsItsDay(detail, original, shifted, zone)
},
// Where the series row's own DTSTART lands, given the anchor moves by
// the same shift. Only read under canRealign, which is what
// guarantees the anchor really does travel this many days.
newAnchorDate = anchorDate(detail, original, zone)
.plus(shifted.start.date.toEpochDays() - original.start.date.toEpochDays(), DateTimeUnit.DAY),
)
}
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) {
val request = prepared.request
if (endsBeforeItStarts(prepared, scope)) {
_outcome.value = MoveOutcome.BlockedSeriesEnd
return
}
_outcome.value = try {
when {
!prepared.isRecurring || scope == RecurringWriteScope.AllEvents ->
repository.updateEvent(request.eventId, prepared.original, prepared.updated)
scope == RecurringWriteScope.ThisEvent ->
repository.updateOccurrence(
request.eventId,
request.beginMillis,
prepared.updated,
)
else -> repository.updateEventFromOccurrence(
eventId = request.eventId,
beginMillis = request.beginMillis,
original = prepared.original,
updated = prepared.updated,
)
}
MoveOutcome.Moved(
startMillis = movedStartMillis(prepared),
isAllDay = prepared.updated.isAllDay,
undo = undoFor(prepared, scope),
)
} catch (e: CancellationException) {
throw e
} catch (e: SecurityException) {
MoveOutcome.WriteDenied
} catch (e: NoSuchEventException) {
MoveOutcome.Gone
} catch (e: Exception) {
MoveOutcome.Failed
}
}
/**
* Whether this write would leave a rule whose `UNTIL` precedes the first day
* it now applies to — the provider then generates nothing at all. Which date
* has to clear `UNTIL` depends on the scope: a whole-series move carries the
* series *anchor*, a split starts at the moved occurrence, and a single
* occurrence becomes an exception row that no `UNTIL` constrains.
*/
private fun endsBeforeItStarts(prepared: PreparedMove, scope: RecurringWriteScope): Boolean {
if (!prepared.isRecurring || scope == RecurringWriteScope.ThisEvent) return false
val rule = prepared.updated.rrule ?: return false
val end = parseSimpleRecurrence(rule)?.end as? RecurrenceEnd.Until ?: return false
val firstDay = if (scope == RecurringWriteScope.AllEvents) {
prepared.newAnchorDate
} else {
prepared.updated.start.date
}
return end.date < firstDay
}
/**
* Whether a same-date drag leaves the series anchor on its own day too. The
* anchor moves by the same wall-clock delta as the occurrence, and normally
* shares its time of day — but a row with no `EVENT_TIMEZONE` resolves the
* two in zones that can sit a DST hour apart, so a near-midnight drag could
* carry the anchor across a midnight the occurrence never crossed and leave
* `BYDAY` naming the wrong weekday.
*/
private fun anchorKeepsItsDay(
detail: EventDetail,
original: EventForm,
shifted: EventForm,
zone: TimeZone,
): Boolean {
val anchorZone = if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone)
val anchorMinute = detail.instance.start.toLocalDateTime(anchorZone)
.time.toMillisecondOfDay() / MILLIS_PER_MINUTE
val delta = (
shifted.start.time.toMillisecondOfDay() - original.start.time.toMillisecondOfDay()
) / MILLIS_PER_MINUTE
return anchorMinute + delta in 0 until MINUTES_PER_DAY
}
/**
* The series row's own start date — for a recurring master, `EventDetail`
* carries the row's DTSTART rather than the tapped occurrence's. Read in the
* same anchoring the write path uses: UTC for an all-day series.
*/
private fun anchorDate(detail: EventDetail, original: EventForm, zone: TimeZone): LocalDate =
detail.instance.start
.toLocalDateTime(if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone))
.date
/**
* Undo is offered only where the inverse is one symmetric write: a
* non-recurring event, and a whole-series move. "This event" leaves an
* exception row behind and "this and following" splits the series with an
* UNTIL truncation — neither is undone by shifting back.
*/
private fun undoFor(prepared: PreparedMove, scope: RecurringWriteScope): MoveUndo? =
if (!prepared.isRecurring || scope == RecurringWriteScope.AllEvents) {
MoveUndo(
eventId = prepared.request.eventId,
moved = prepared.updated,
restored = prepared.original,
)
} else {
null
}
/**
* Where the dragged occurrence now begins, in the same anchoring the views
* read dates back in: UTC midnight for an all-day event.
*/
private fun movedStartMillis(prepared: PreparedMove): Long {
val form = prepared.updated
return if (form.isAllDay) {
form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
} else {
form.start.toInstant(form.resolvedZone(TimeZone.currentSystemDefault()))
.toEpochMilliseconds()
}
}
}

View File

@@ -17,6 +17,7 @@ val LocalUse24HourFormat = staticCompositionLocalOf { true }
private const val PATTERN_24 = "HH:mm"
private const val PATTERN_12 = "h:mm a"
private const val HOUR_PATTERN_12 = "h a"
private const val HOUR_MINUTE_PATTERN_12 = "h:mm"
/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */
fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter =
@@ -42,6 +43,18 @@ fun formatMinuteOfDay(minutes: Int, is24Hour: Boolean, locale: Locale): String =
else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale)
}
/**
* The time shown in the timeline gutter while a block is dragged: 24h →
* "09:15", 12h → "9:15". The meridiem is dropped — the hour labels around it
* already carry it, and the gutter is too narrow.
*/
fun formatGutterTime(minutes: Int, is24Hour: Boolean, locale: Locale): String {
val clamped = minutes.coerceIn(0, MINUTES_PER_DAY - 1)
val pattern = if (is24Hour) PATTERN_24 else HOUR_MINUTE_PATTERN_12
return LocalTime.of(clamped / 60, clamped % 60)
.format(DateTimeFormatter.ofPattern(pattern, locale))
}
/**
* The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded,
* the prior look); 12h → "1 PM".

View File

@@ -0,0 +1,484 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.delay
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.math.roundToInt
import kotlin.time.Instant
/** Start granularity a dragged block snaps to, matching the big calendar apps. */
const val DRAG_SNAP_MINUTES: Int = 15
/** How close to a timeline edge the finger must get before the view scrolls. */
private val AUTO_SCROLL_EDGE = 64.dp
/** Fastest auto-scroll step, per frame, right at the edge. */
private val AUTO_SCROLL_STEP = 16.dp
/** A block being dragged, in root coordinates so it can be drawn in an overlay. */
data class TimelineDrag(
val event: EventInstance,
val date: LocalDate,
val startMin: Int,
val endMin: Int,
val topLeftInRoot: Offset,
val sizePx: IntSize,
) {
/** What the snap haptics key off: one tick per changed slot, not per frame. */
val slot: Pair<LocalDate, Int> get() = date to startMin
}
/** Where a finished drag asks its event to go. */
data class TimelineDrop(val event: EventInstance, val date: LocalDate, val startMin: Int)
/**
* The timeline's live geometry, republished on every layout. Plain fields rather
* than snapshot state: it changes on every scroll frame, and the drag loop reads
* it per frame anyway.
*/
class TimelineGeometry {
/** The day-columns row — scrolling *content*, so its root position folds in the scroll. */
var grid: LayoutCoordinates? = null
/** The scroll viewport, for the edge that triggers auto-scrolling. */
var viewport: LayoutCoordinates? = null
var scroll: ScrollState? = null
var hourPx: Float = 0f
/** Column pitch — one column plus the gap after it. */
var columnWidthPx: Float = 0f
var columnGapPx: Float = 0f
var edgePx: Float = 0f
var stepPx: Float = 0f
var days: List<LocalDate> = emptyList()
/**
* Whether the columns are laid out right-to-left. Pointer coordinates are
* never mirrored but the grid is, so the mapping has to flip with it.
*/
var isRtl: Boolean = false
}
/**
* Hoisted drag state for one timeline (#68). It lives above the per-page
* `AnimatedContent`, so a page change mid-drag can't strand a ghost, and the
* block it renders is drawn in an overlay, clear of the day column's clip.
*/
@Stable
class TimelineDragController {
val geometry = TimelineGeometry()
var drag: TimelineDrag? by mutableStateOf(null)
private set
/**
* A dropped block, held at the slot it landed on while the write runs — the
* grid behind it still shows the old time until the query re-reads.
*/
var settling: TimelineDrag? by mutableStateOf(null)
private set
/**
* Which block is lifted, and whether anything is. Separate snapshot state
* from [drag], which changes every frame, so blocks that only need "am I the
* ghost" don't recompose that often. Stays set through [settling].
*/
var liftedInstanceId: Long? by mutableStateOf(null)
private set
/**
* Where the settled drop came from, and which event row it belongs to. The
* instance id alone can't identify the source block for the length of the
* write: the provider regenerates `Instances` rows, so a re-read carrying the
* *old* time can arrive under a new instance id.
*/
private var settledOrigin: Triple<Long, LocalDate, Int>? by mutableStateOf(null)
/** Whether a finger is on a block right now — [settling] is not dragging. */
var isDragging: Boolean by mutableStateOf(false)
private set
/**
* Whether the grid itself now draws the settled drop at its landing slot.
* The copy may only be handed back once this is true, or the source ghost
* flashes back to full opacity in its old slot.
*/
var settledOnGrid: Boolean by mutableStateOf(false)
private set
private var source: TimedBlock? = null
private var grab = Offset.Zero
private var pointer = Offset.Zero
/**
* The slot the block already occupied when it was picked up — not the same
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
*/
private var originSlot: Pair<LocalDate, Int>? = null
fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
source = block
settling = null
isDragging = true
liftedInstanceId = block.event.instanceId
grab = pointerInRoot - blockInRoot
pointer = pointerInRoot
originSlot = null
recompute()
originSlot = drag?.slot
}
fun move(pointerInRoot: Offset) {
pointer = pointerInRoot
recompute()
}
fun cancel() {
source = null
isDragging = false
liftedInstanceId = null
originSlot = null
drag = null
settling = null
settledOnGrid = false
settledOrigin = null
}
/**
* Whether [block] is the one whose copy is in flight, and so must stay a
* ghost: the instance the finger picked up, or once dropped whatever now
* sits in the slot it left.
*/
fun ghosts(block: TimedBlock, date: LocalDate): Boolean {
if (liftedInstanceId == null) return false
if (block.event.instanceId == liftedInstanceId) return true
val (eventId, originDate, originMin) = settledOrigin ?: return false
return block.event.eventId == eventId &&
date == originDate &&
block.startMin == originMin
}
/**
* What a day column now holds, so a settled drop can tell when the grid has
* caught up with it. Matched on the landing slot plus either the event row
* or its title, since a single-occurrence move writes a new `eventId`.
*/
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
val landed = settling ?: return
if (settledOnGrid || landed.date != date) return
settledOnGrid = blocks.any { block ->
block.startMin == landed.startMin &&
(
block.event.eventId == landed.event.eventId ||
block.event.title == landed.event.title
)
}
}
/**
* End the drag, handing back where it landed — null when it never resolved,
* or when it landed back on the slot it started from. A real drop keeps its
* copy on the target as [settling] until [release].
*/
fun finish(): TimelineDrop? {
val landed = drag
val origin = originSlot
cancel()
if (landed == null || landed.slot == origin) return null
settling = landed
liftedInstanceId = landed.event.instanceId
settledOrigin = origin?.let { (date, min) -> Triple(landed.event.eventId, date, min) }
return TimelineDrop(landed.event, landed.date, landed.startMin)
}
/**
* Stop ghosting the source — the grid draws the drop itself by now — while
* the copy is still dissolving. Un-ghosting at [release] instead would leave
* the block dim under the fading copy and then brighten it: a visible dip.
*/
fun handOver() {
liftedInstanceId = null
settledOrigin = null
}
/** Drop the copy, once it has faded into the grid's own block. */
fun release() {
settling = null
settledOnGrid = false
handOver()
}
/**
* Re-derive the target from the pointer's *root* position. Recomputed rather
* than accumulated from `positionChange()`, because a stationary finger emits
* no move events while auto-scroll walks the content underneath it.
*/
fun recompute() {
val block = source ?: return
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
val hourPx = geometry.hourPx
val columnPx = geometry.columnWidthPx
val days = geometry.days
if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return
val origin = grid.positionInRoot()
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
// Clamp the start into the target day; a tail past midnight is fine.
val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
// The event's own length, not the block's: TimedBlock.endMin is clipped
// at midnight, which would draw a 22:0002:00 event as a two-hour copy.
val span = (block.event.end - block.event.start).inWholeMinutes.toInt().coerceAtLeast(0)
// The column the finger is over on screen, and the day that column shows.
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
drag = TimelineDrag(
event = block.event,
date = days[dayIndex],
startMin = startMin,
endMin = startMin + span,
topLeftInRoot = Offset(
x = origin.x + column * columnPx,
y = origin.y + startMin / 60f * hourPx,
),
sizePx = IntSize(
(columnPx - geometry.columnGapPx).roundToInt(),
height.roundToInt(),
),
)
}
/**
* Hold the scroll for the whole drag and nudge it once per frame while the
* finger sits near an edge. One `scroll` call, since a loop of
* `animateScrollBy` would re-acquire the mutex and cancel the last.
*/
suspend fun autoScroll() {
val scroll = geometry.scroll ?: return
scroll.scroll(MutatePriority.UserInput) {
while (true) {
withFrameNanos { }
recompute()
val step = edgeStep()
if (step != 0f) scrollBy(step)
}
}
}
private fun edgeStep(): Float {
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return 0f
val edge = geometry.edgePx
if (edge <= 0f) return 0f
val bounds = viewport.boundsInRoot()
val fromTop = pointer.y - bounds.top
val fromBottom = bounds.bottom - pointer.y
return when {
fromTop < edge -> -(edge - fromTop) / edge * geometry.stepPx
fromBottom < edge -> (edge - fromBottom) / edge * geometry.stepPx
else -> 0f
}
}
}
@Composable
fun rememberTimelineDragController(): TimelineDragController {
val controller = remember { TimelineDragController() }
val density = LocalDensity.current
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
return controller
}
/**
* True when [block] actually begins on [day] rather than being the tail of an
* event that started earlier. A clipped block's top edge is midnight, not the
* event's start, so dragging it would move the event to a time it never had.
*/
fun TimedBlock.beginsOn(day: LocalDate, zone: TimeZone): Boolean =
event.start.toLocalDateTime(zone).date == day
/**
* The instant a drop asks for, read in the zone the timeline is drawn in. A drop
* into a spring-forward gap has no instant of its own; `toInstant` resolves it
* forward by the missing hour, and the block visibly settles there.
*/
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone)
/**
* A beat after the grid draws the drop, so its own block is under way before the
* copy starts dissolving.
*/
const val SETTLE_GRACE_MILLIS: Long = 60L
/**
* How long to wait for a grid that never confirms the drop — it landed on a day
* this timeline doesn't show, or the write failed. The copy has to go either way.
*/
private const val SETTLE_TIMEOUT_MILLIS = 900L
/**
* The hand-over: the copy dissolves over this while the grid's own block slides
* in under it. Kept short, because a full-width copy sits over any neighbour it
* now shares a lane with until it is gone.
*/
const val SETTLE_FADE_MILLIS: Int = 250
/**
* The floating block, drawn over the whole calendar so it is free of the day
* column's clip and of the scroll viewport's rounded corners.
*/
@Composable
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
var origin by remember { mutableStateOf(Offset.Zero) }
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val reduceMotion = rememberReduceMotion()
val density = LocalDensity.current
val moveInFlight = moveInFlight()
// Both live here rather than beside the controller: they read [drag], which
// changes every frame, and this composable is the one that is meant to.
DragSnapHaptics(controller.drag?.slot)
LaunchedEffect(controller.isDragging) {
if (controller.isDragging) controller.autoScroll()
}
// Hold the landed copy until the write is done — including the time a
// recurring drop's scope dialog is up — and then until the grid draws the
// drop itself.
var handingOver by remember(controller.settling) { mutableStateOf(false) }
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) {
if (controller.settling == null || moveInFlight) return@LaunchedEffect
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
handingOver = true
controller.handOver()
delay(SETTLE_FADE_MILLIS.toLong())
controller.release()
}
Box(
modifier = modifier
.fillMaxSize()
// A copy of a block still in the tree behind it; announcing it again
// would duplicate the event for the drag's duration.
.clearAndSetSemantics { }
.onGloballyPositioned { origin = it.positionInRoot() },
) {
val drag = controller.drag ?: controller.settling ?: return@Box
// Landed: the copy sinks back to the grid's own plane while the write runs.
val landed = controller.drag == null
val lift by animateFloatAsState(
targetValue = if (landed || reduceMotion) 0f else 1f,
label = "drag-lift",
)
val copyAlpha by animateFloatAsState(
targetValue = if (handingOver) 0f else 1f,
animationSpec = tween(SETTLE_FADE_MILLIS),
label = "drag-handover",
)
val fill = eventFill(drag.event.color, dark, soften)
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
// An end past midnight wraps rather than saturating, so a four-hour
// event dragged to 22:00 reads "22:0002:00" and not "22:0024:00".
val endMin = if (drag.endMin > MINUTES_PER_DAY) {
drag.endMin % MINUTES_PER_DAY
} else {
drag.endMin
}
val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}" +
formatMinuteOfDay(endMin, use24Hour, locale)
Box(
modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware
// offset would mirror them across the screen in an RTL layout.
.absoluteOffset {
IntOffset(
(drag.topLeftInRoot.x - origin.x).roundToInt(),
(drag.topLeftInRoot.y - origin.y).roundToInt(),
)
}
.size(
width = with(density) { drag.sizePx.width.toDp() },
height = with(density) { drag.sizePx.height.toDp() },
)
.padding(horizontal = 1.dp)
.graphicsLayer {
scaleX = 1f + 0.02f * lift
scaleY = 1f + 0.02f * lift
shadowElevation = 8.dp.toPx() * lift
alpha = copyAlpha
shape = RoundedCornerShape(4.dp)
clip = false
}
.background(fill, RoundedCornerShape(4.dp))
.padding(horizontal = 4.dp, vertical = 2.dp),
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f),
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
)
}
}
}
}

View File

@@ -0,0 +1,203 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.annotation.StringRes
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/**
* How tall one hour is drawn in the week and day timelines (#56).
*
* One shared setting for both views: they are the same grid at different widths,
* and a scale that only applied to one of them would read as a bug.
*
* [FitDay] is the answer to the actual complaint behind the issue — on a tall
* phone the default scale shows about half a day, so a whole week can hide
* appointments below the fold. It derives the hour height from the timeline's
* own viewport instead of a fixed value.
*
* [Custom] is what a pinch on the timeline leaves behind. The presets are the
* settings-screen vocabulary; a pinch is a direct manipulation and should be
* able to land anywhere between them, so it gets its own case rather than
* snapping to the nearest named step.
*/
sealed interface TimelineScale {
/** Whole day in one screen: the hour height follows the viewport. */
data object FitDay : TimelineScale
/** Denser than the default, still a fixed height. */
data object Compact : TimelineScale
/** The historical 56dp scale. */
data object Regular : TimelineScale
/** Roomier blocks, more scrolling. */
data object Comfortable : TimelineScale
/** A height the user pinched to. Build it through [custom], which clamps. */
data class Custom(val hourHeight: Dp) : TimelineScale
companion object {
/** The named steps the settings picker offers, coarse to roomy. */
val presets: List<TimelineScale> = listOf(FitDay, Compact, Regular, Comfortable)
/**
* A pinched hour height. The floor a pinch actually stops at depends on
* the viewport and is applied when the height is resolved (see
* [hourHeight]); this only holds a stored value to something sane.
*/
fun custom(hourHeight: Dp): Custom =
Custom(hourHeight.coerceIn(MIN_STORED_HOUR_HEIGHT, MAX_PINCH_HOUR_HEIGHT))
}
}
/**
* Hour height for this scale. [viewportHeight] is the visible height of the
* scrolling timeline and is only consulted by [TimelineScale.FitDay].
*
* The fit-day result is clamped: below [FIT_DAY_MIN] the 24 gutter labels stop
* being legible, and above [FIT_DAY_MAX] a short landscape day would stretch its
* blocks absurdly. On a screen too short for the whole day the clamp wins and
* the timeline still scrolls a little — honest, rather than unreadable.
*
* A pinched height is held to [fillHourHeight] here and not only in the gesture,
* because the viewport can change under a height that was already stored: pinch
* all the way out in landscape and the same value would leave portrait with dead
* space under midnight.
*/
fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
TimelineScale.FitDay -> (viewportHeight / 24f).coerceIn(FIT_DAY_MIN, FIT_DAY_MAX)
TimelineScale.Compact -> 40.dp
TimelineScale.Regular -> 56.dp
TimelineScale.Comfortable -> 80.dp
is TimelineScale.Custom -> this.hourHeight
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
.coerceAtLeast(fillHourHeight(viewportHeight))
}
/**
* The minute of the day a tap [offsetY] px down a timeline column means, snapped
* to the hour it landed in.
*
* [hourPx] must be the hour height the column is drawing at *now* — a tap
* detector that captured it when it was installed maps taps to a pre-pinch grid
* (#148). A zero or negative height (a viewport measured at nothing) has no grid
* to read, so it answers midnight rather than dividing by it.
*/
fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
if (hourPx <= 0f) 0 else (offsetY / hourPx).toInt().coerceIn(0, 23) * 60
/**
* Shortest an event block may render, as a fraction of an hour. Blocks keep a
* floor so a 15-minute event stays tappable, but the floor scales with the hour
* height — a fixed 24dp would swallow half an hour once zoomed out and make
* short events overlap their neighbours.
*/
const val MIN_EVENT_FRACTION = 26f / 60f
/**
* Narrowest an event block may be and still wrap its title over several lines.
*
* Wrapping is driven by the block's height, so a tall block on a lane-split
* column would otherwise stack two or three characters per line — "Da/ily",
* "Fa/rmer/s…" — which reads worse than one ellipsised line. A full week column
* clears this on any phone; a split one never does, while the day view's much
* wider columns keep wrapping even several lanes deep.
*/
val MIN_TITLE_WRAP_WIDTH = 36.dp
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */
val FIT_DAY_MIN = 24.dp
/** Largest hour height [TimelineScale.FitDay] will resolve to. */
val FIT_DAY_MAX = 96.dp
/**
* The hour height at which all 24 hours exactly fill [viewportHeight] — how far
* out a pinch can zoom.
*
* Below it there is no more day left to uncover, so the only thing shrinking
* further buys is empty space under midnight. Zooming out means "show me more of
* the day", and once the whole day is on screen that request is answered.
*/
fun fillHourHeight(viewportHeight: Dp): Dp = viewportHeight / 24f
/**
* Ceiling for a pinch, deliberately far above [TimelineScale.Comfortable]: a
* pinch is a deliberate act, so someone zooming in on a single busy afternoon
* should be allowed to go further than any preset offers.
*/
val MAX_PINCH_HOUR_HEIGHT = 240.dp
/**
* Floor for a *stored* pinched height, which only has to stay sane enough to be
* re-clamped against whatever viewport it is later shown in. The floor a pinch
* stops at is [fillHourHeight].
*/
private val MIN_STORED_HOUR_HEIGHT = 1.dp
@get:StringRes
val TimelineScale.labelRes: Int
get() = when (this) {
TimelineScale.FitDay -> R.string.timeline_scale_fit_day
TimelineScale.Compact -> R.string.timeline_scale_compact
TimelineScale.Regular -> R.string.timeline_scale_regular
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable
is TimelineScale.Custom -> R.string.timeline_scale_custom
}
@get:StringRes
val TimelineScale.descriptionRes: Int
get() = when (this) {
TimelineScale.FitDay -> R.string.timeline_scale_fit_day_summary
TimelineScale.Compact -> R.string.timeline_scale_compact_summary
TimelineScale.Regular -> R.string.timeline_scale_regular_summary
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary
is TimelineScale.Custom -> R.string.timeline_scale_custom_summary
}
/** Marks a stored custom height; the rest of the value is its dp. */
private const val CUSTOM_PREFIX = "custom:"
/**
* Stored names for the presets. Spelled out rather than taken from `toString()`
* so R8 can't rename them out from under an existing install — and they match
* the enum names this used to be, so a value written before the pinch existed
* still reads back as the same preset.
*/
private val PRESET_NAMES: Map<TimelineScale, String> = mapOf(
TimelineScale.FitDay to "FitDay",
TimelineScale.Compact to "Compact",
TimelineScale.Regular to "Regular",
TimelineScale.Comfortable to "Comfortable",
)
/**
* Serialise for the `timeline_scale` preference; see [parseTimelineScale].
*
* Spelled out rather than an `else` into [PRESET_NAMES], so a preset added later
* without a stored name is a compile error here instead of a crash the first
* time someone picks it.
*/
fun TimelineScale.storageValue(): String = when (this) {
is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value
TimelineScale.FitDay,
TimelineScale.Compact,
TimelineScale.Regular,
TimelineScale.Comfortable,
-> PRESET_NAMES.getValue(this)
}
/**
* Parse a stored scale. Anything unrecognised — a null key, a name from a
* future release, a truncated custom height — falls back to
* [TimelineScale.Regular] rather than throwing, like the other enum prefs.
*/
fun parseTimelineScale(stored: String?): TimelineScale = when {
stored == null -> TimelineScale.Regular
stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull()
?.let { TimelineScale.custom(it.dp) }
?: TimelineScale.Regular
else -> PRESET_NAMES.entries.firstOrNull { it.value == stored }?.key ?: TimelineScale.Regular
}

View File

@@ -0,0 +1,237 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculateCentroid
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt
/**
* The scale the timelines draw at right now: the stored preference, or whatever
* a pinch is currently doing to it (#56).
*
* A pinch changes the scale on every pointer frame, which the preference alone
* cannot carry — a DataStore round trip per frame would lag the gesture badly
* behind the fingers. So the live value lives here in composition state and only
* the settled result is written back, once, when the fingers lift.
*/
@Stable
class TimelineZoom(
initial: TimelineScale,
private val persist: (TimelineScale) -> Unit,
) {
/** What the week and day timelines should draw at. */
var scale: TimelineScale by mutableStateOf(initial)
private set
/**
* Whether fingers are rescaling the timeline right now. Snapshot state, so
* anything that tweens off [scale] can stand down for the gesture: the
* height changes every pointer frame, and a spring would spend the whole
* pinch chasing a target that has already moved.
*/
var isPinching: Boolean by mutableStateOf(false)
private set
/**
* Take a value that came from the preference. Ignored mid-pinch: the stored
* value is a frame or two behind the fingers there, and letting it land would
* snap the timeline back while the user is still pinching.
*/
fun adopt(stored: TimelineScale) {
if (!isPinching) scale = stored
}
fun beginPinch() {
isPinching = true
}
fun pinchTo(hourHeight: Dp) {
scale = TimelineScale.custom(hourHeight)
}
fun endPinch() {
isPinching = false
persist(scale)
}
}
/**
* The zoom the timelines read. Falls back to a detached instance — one shared
* lazy value, not one per read, or each read would hand out a fresh state — so a
* preview or test can render a timeline without the activity's provider.
*/
private val DetachedTimelineZoom by lazy { TimelineZoom(TimelineScale.Regular) {} }
val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom }
/**
* The app-wide [TimelineZoom], seeded from [stored] and writing settled pinches
* back through [onPersist].
*/
@Composable
fun rememberTimelineZoom(
stored: TimelineScale,
onPersist: (TimelineScale) -> Unit,
): TimelineZoom {
val persist by rememberUpdatedState(onPersist)
val zoom = remember { TimelineZoom(stored) { persist(it) } }
// Picking a preset in Settings has to reach the timelines, and so does the
// stored value arriving after the first frame.
LaunchedEffect(stored) { zoom.adopt(stored) }
return zoom
}
/**
* Two-finger pinch that rescales a 24-hour timeline (#56) — the gesture the
* issue actually asked for; the Settings presets stay as the accessible route to
* the same thing.
*
* Three gestures share this area, so the pinch is deliberately the fussiest
* about claiming it: nothing happens until a *second* finger is down and the
* spread has grown past [PINCH_SLOP], which leaves one-finger vertical scrolling
* and the horizontal week swipe untouched, and lets a two-finger drag still
* scroll. It watches on [PointerEventPass.Initial] because the scroll it has to
* outrank is a descendant — on the main pass the scroll would have consumed the
* drag before this ever saw it.
*
* [hourHeight] is the *resolved* height, so a pinch that starts from
* `FitDay` picks up where the viewport left it rather than jumping.
* [viewportHeight] sets how far out it can go — see [fillHourHeight].
*/
@Composable
fun rememberTimelinePinchZoom(
scrollState: ScrollState,
viewportHeight: Dp,
hourHeight: Dp,
zoom: TimelineZoom,
): Modifier {
// The gesture loop outlives any single composition, so it reads these
// through state handles rather than capturing what they were when it started.
val currentHourHeight = rememberUpdatedState(hourHeight)
val currentViewport = rememberUpdatedState(viewportHeight)
return Modifier.pointerInput(scrollState, zoom) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
var claimed = false
var slop = 1f
// Scroll the layout could not give us yet, carried to the next frame
// (see anchoredScroll).
var pending = 0f
try {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
if (event.changes.none { it.pressed }) break
if (event.changes.count { it.pressed } >= 2) {
val step = event.calculateZoom()
if (!claimed) {
slop *= step
if (abs(slop - 1f) >= PINCH_SLOP) {
claimed = true
zoom.beginPinch()
}
}
if (claimed) {
val old = currentHourHeight.value.toPx()
val new = pinchedHourHeightPx(
target = old * step,
fillPx = fillHourHeight(currentViewport.value).toPx(),
maxPx = MAX_PINCH_HOUR_HEIGHT.toPx(),
)
// Half a pixel, not equality: the height round-trips
// through dp and back, and an exact test would read
// the float noise that comes back as a scale change
// and feed the scroll a delta on every frame of a
// held pinch.
if (abs(new - old) >= 0.5f) {
val centroidY = event.calculateCentroid(useCurrent = true).y
pending += anchoredScroll(scrollState.value, centroidY, old, new) -
scrollState.value
zoom.pinchTo(new.toDp())
}
pending -= scrollState.dispatchRawDelta(pending)
}
}
// Hold the gesture to the end once it has become a pinch:
// letting go the moment a finger lifts would turn the tail of
// a zoom into a scroll, and the taps underneath into a new event.
if (claimed) event.changes.forEach { if (it.pressed) it.consume() }
}
} finally {
// In a finally because the gesture can also end by having its
// pointer node disposed mid-pinch — the timeline swapping out
// under the fingers. The zoom outlives that node, and a pinch
// left open would make it ignore every later Settings change.
if (claimed) zoom.endPinch()
}
}
}
}
/**
* Where a pinch aiming at [target] px per hour actually lands: clamped between
* "the whole day fills the screen" ([fillPx]) and [maxPx], then rounded to a
* whole pixel.
*
* The rounding is not cosmetic. The hour gutter is 24 stacked boxes one hour
* tall, and each rounds its own height to whole pixels, while the hour lines and
* event blocks are drawn at the fractional height — so a height of 56.4px lays
* the labels out at 56px and leaves the 23:00 label ~9px above the line it
* names, jumping the whole column as a pinch drifts across each half pixel.
* Pinning the hour to whole pixels keeps every part of the timeline on one grid.
*
* The bounds themselves are pulled onto that grid too, each in the direction
* that keeps its own promise — up for the fill floor, so no dead space opens
* under midnight, down for the ceiling. A fractional bound would be a height the
* pinch can be held against but never actually land on, and the difference feeds
* the focal anchor a scroll correction on every frame the fingers sit still.
*/
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
// Filling the viewport wins over the ceiling: on a screen tall enough for
// the two to disagree, dead space is the worse of the two failures.
target.roundToInt().toFloat()
.coerceAtMost(floor(maxPx))
.coerceAtLeast(ceil(fillPx))
/**
* The scroll offset that keeps the moment under [centroidY] under it after the
* hour height changes from [oldHourPx] to [newHourPx].
*
* Anchoring on the fingers is what makes a zoom feel like the day is being
* stretched rather than replaced: without it, zooming in on the evening walks
* the evening off the bottom of the screen.
*
* The caller may not get all of this at once — the content only grows on the
* next layout pass, so scrolling further down than the *current* content allows
* is refused — which is why the shortfall is carried over and re-offered.
*/
internal fun anchoredScroll(
scroll: Int,
centroidY: Float,
oldHourPx: Float,
newHourPx: Float,
): Float = ((scroll + centroidY) / oldHourPx) * newHourPx - centroidY
/**
* How far the fingers must spread or close before the pinch takes over. Small
* enough to feel immediate, wide enough that the two-finger scroll a user meant
* as a scroll stays one.
*/
private const val PINCH_SLOP = 0.08f

View File

@@ -0,0 +1,145 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlin.math.roundToInt
/**
* The viewport a view preview pretends to be: a phone's width by the height a
* calendar view gets under the top bar. Scaling every preview from one fixed
* height keeps them comparable — each shows the same slice of screen.
*/
internal val PREVIEW_VIEWPORT_HEIGHT = 440.dp
/**
* Renders [content] at a full phone viewport and shrinks the finished layout
* into a box [height] tall, for the settings and onboarding choosers. Callers
* pass the *real* view composables, so a preview cannot drift from what it
* depicts.
*
* The [layout] below ignores the incoming constraints so the view measures at a
* phone viewport, and a [graphicsLayer] scale shrinks the result.
* `Modifier.requiredSize` is the obvious alternative but *centres* content that
* overflows its constraints, leaving only the bottom-right corner in the clip.
*/
@Composable
internal fun ScaledViewPreview(
height: Dp,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val scale = height.value / PREVIEW_VIEWPORT_HEIGHT.value
Box(
modifier = modifier
.clipToBounds()
.background(MaterialTheme.colorScheme.surface)
// A preview is a picture, not a control: swallow touches before the
// view's own clickables see them, and give screen readers one label
// instead of six weeks of day cells.
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent(PointerEventPass.Initial).changes
.forEach { it.consume() }
}
}
}
.clearAndSetSemantics { }
// Measure at a full phone viewport but report the scaled size, so the
// node occupies exactly what it draws.
.layout { measurable, _ ->
val fullWidth = screenWidth.roundToPx()
val fullHeight = PREVIEW_VIEWPORT_HEIGHT.roundToPx()
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
placeable.place(0, 0)
}
}
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(0f, 0f)
},
) {
Column(Modifier.fillMaxSize()) { content() }
}
}
/**
* The stage every preview picker sets its preview on, cross-fading as [selected]
* changes. A preview draws the app's own surface, so a tonal plate gives it the
* edge it would otherwise lack on a chooser screen; the plate takes the width
* and outer radius of the grouped rows underneath so the two read as one stack.
*
* Only vertical spacing is the caller's to pass — the horizontal inset is the
* rows' own, and the alignment is the point.
*/
@Composable
internal fun <T> ViewPreviewFrame(
selected: T,
label: String,
modifier: Modifier = Modifier,
content: @Composable (T) -> Unit,
) {
val reduceMotion = rememberReduceMotion()
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = GroupedListInset)
.clip(FRAME_SHAPE)
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(FRAME_INSET),
contentAlignment = Alignment.Center,
) {
Crossfade(
targetState = selected,
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
label = label,
) { shown ->
// Each preview renders the real view at phone size and scales it
// down, so its own corners are square — the plate rounds them.
Box(
modifier = Modifier
.clip(PREVIEW_SHAPE)
.background(MaterialTheme.colorScheme.surface)
.clipToBounds(),
) {
content(shown)
}
}
}
}
private val FRAME_INSET = 8.dp
/** The grouped rows' own outer radius, so the stack shares one silhouette. */
private val FRAME_SHAPE = RoundedCornerShape(22.dp)
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)

View File

@@ -42,9 +42,11 @@ import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -53,14 +55,19 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -68,12 +75,28 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.beginsOn
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
@@ -83,13 +106,22 @@ import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
import de.jeanlucmakiola.calendula.ui.common.HourGutter
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -100,18 +132,11 @@ import kotlin.time.Clock
import java.util.Locale
import kotlin.math.roundToInt
private val HOUR_HEIGHT = 56.dp
private val GUTTER_WIDTH = 48.dp
/** Start inset for the gutter's hour labels so they centre on the top bar's
* hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's
* 4dp inset + 24dp half icon button), matching the week view. */
private val GUTTER_CONTENT_START_INSET = 8.dp
private val MIN_EVENT_HEIGHT = 24.dp
private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp
/** Total all-day strip height for the day (0 when there are no all-day events). */
private fun DayUiState.Success.allDayStripHeight(): Dp {
internal fun DayUiState.Success.allDayStripHeight(): Dp {
if (allDay.isEmpty()) return 0.dp
val lanes = allDay.maxOf { it.lane } + 1
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
@@ -195,6 +220,7 @@ fun DayScreen(
CalendarDrawer(
currentView = selectedView,
currentDate = date,
drawerState = drawerState,
viewOrder = drawerViewOrder,
onSelectView = { view ->
onSelectView(view)
@@ -221,6 +247,7 @@ fun DayScreen(
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
scrollBehavior = scrollBehavior,
@@ -264,7 +291,6 @@ private fun DayContent(
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
@@ -275,11 +301,9 @@ private fun DayContent(
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
val maxV = scrollState.maxValue
val target = with(density) {
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
}.coerceIn(0, maxV)
scrollState.scrollTo(target)
// Half the scroll range *is* noon: the content spans a full 24 hours, so
// centring the range centres midday at whatever hour height is in force.
scrollState.scrollTo(scrollState.maxValue / 2)
}
// Single, hoisted all-day strip height — shared by the outgoing and incoming
@@ -295,42 +319,67 @@ private fun DayContent(
// drag is consumed by the inner scroll first — the two gestures coexist.
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
AnimatedContent(
targetState = state,
modifier = modifier.then(swipeModifier),
contentKey = { s ->
// Above the AnimatedContent: a page change mid-drag would strand the
// floating block inside the outgoing page.
val dragController = rememberTimelineDragController()
val move = LocalEventMove.current
val zone = remember { TimeZone.currentSystemDefault() }
Box(modifier = modifier) {
AnimatedContent(
targetState = state,
modifier = Modifier.fillMaxSize().then(swipeModifier),
contentKey = { s ->
when (s) {
is DayUiState.Success -> "success-${s.date}"
is DayUiState.Failure -> "failure-${s.reason}"
DayUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "day-transition",
) { s ->
when (s) {
is DayUiState.Success -> "success-${s.date}"
is DayUiState.Failure -> "failure-${s.reason}"
DayUiState.Loading -> "loading"
DayUiState.Loading -> DayLoading()
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is DayUiState.Success -> DaySuccess(
state = s,
topSectionColor = topSectionColor,
scrollState = scrollState,
allDayHeight = allDayHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = { drop ->
val took = move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Start(drop.startInstant(zone)),
),
)
// Refused, so nothing will land: let the copy go now
// rather than hold it out for a settle that never comes.
if (took != true) dragController.release()
},
)
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "day-transition",
) { s ->
when (s) {
DayUiState.Loading -> DayLoading()
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is DayUiState.Success -> DaySuccess(
state = s,
topSectionColor = topSectionColor,
scrollState = scrollState,
allDayHeight = allDayHeight,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
)
}
TimelineDragOverlay(dragController)
}
}
@Composable
private fun DaySuccess(
internal fun DaySuccess(
state: DayUiState.Success,
topSectionColor: Color,
scrollState: ScrollState,
allDayHeight: Dp,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
// All-day strip collapses to nothing when the day has no all-day events,
@@ -349,8 +398,10 @@ private fun DaySuccess(
Timeline(
state = state,
scrollState = scrollState,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
)
}
}
@@ -364,6 +415,7 @@ private fun DayTopBar(
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
@@ -371,9 +423,10 @@ private fun DayTopBar(
val locale = currentLocale()
TopAppBar(
title = {
Text(
text = formatDayTitle(date, locale, currentYear),
style = MaterialTheme.typography.titleLarge,
CalendarTitleButton(
title = formatDayTitle(date, locale, currentYear),
currentDate = date,
onJumpToDate = onJumpToDate,
)
},
navigationIcon = {
@@ -420,7 +473,11 @@ private fun AllDayStrip(
// Height is hoisted + animated so it resizes smoothly; padding sits
// inside it so the content area is lanes * row height.
.height(height)
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
.padding(
top = ALL_DAY_VERTICAL_PADDING,
bottom = ALL_DAY_VERTICAL_PADDING,
end = TIMELINE_CONTENT_END_INSET,
),
) {
// Keep the gutter-width offset so the bars line up with the day column.
Spacer(Modifier.width(GUTTER_WIDTH))
@@ -474,6 +531,7 @@ private fun AllDayBar(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
)
}
}
@@ -482,66 +540,74 @@ private fun AllDayBar(
private fun Timeline(
state: DayUiState.Success,
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme()
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val zoom = LocalTimelineZoom.current
val density = LocalDensity.current
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
Box(modifier = Modifier.fillMaxSize()) {
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
// timeline's own viewport height, which is only known here — below the top
// bar, date header and all-day strip.
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val hourHeight = zoom.scale.hourHeight(maxHeight)
val totalHeight = hourHeight * 24
// The pinch sits on the Row, above both scroll viewports: it has to
// outrank the vertical scroll, and it does that by watching the initial
// pass, which only reaches it if it is their ancestor.
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
// Gutter and day column are two scroll viewports that SHARE one scroll
// state, so they stay perfectly aligned. The day-column viewport is a
// static, rounded-clipped window — the content scrolls inside it, so the
// soft corners are permanent at any scroll position.
Row(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
// Hour gutter (scrolls in sync with the day column). Start inset so the
// labels centre on the top bar hamburger, matching the week view.
Column(
modifier = Modifier
.width(GUTTER_WIDTH)
.padding(start = GUTTER_CONTENT_START_INSET)
.fillMaxHeight()
.verticalScroll(scrollState),
) {
(0 until 24).forEach { h ->
Box(
modifier = Modifier
.fillMaxWidth()
.height(HOUR_HEIGHT),
) {
if (h > 0) {
Text(
text = formatHourLabel(h, use24Hour, locale),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = (-6).dp),
)
}
}
}
}
HourGutter(
scrollState = scrollState,
hourHeight = hourHeight,
dragController = dragController,
)
// Day column: rounded, clipped scroll viewport (permanent corners).
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState),
.verticalScroll(scrollState)
.onGloballyPositioned { dragController.geometry.viewport = it },
) {
DayColumnCard(
blocks = state.timed,
dark = dark,
date = state.date,
today = state.today,
hourHeight = hourHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
modifier = Modifier
.fillMaxWidth()
.height(totalHeight),
.height(totalHeight)
// The scrolling content itself, so its root position
// already folds in the scroll offset.
.onGloballyPositioned { coords ->
dragController.geometry.let {
it.grid = coords
it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() }
it.columnGapPx = 0f
it.columnWidthPx = coords.size.width.toFloat()
it.days = listOf(state.date)
it.isRtl = isRtl
}
},
)
}
}
@@ -554,13 +620,26 @@ private fun DayColumnCard(
dark: Boolean,
date: LocalDate,
today: LocalDate,
hourHeight: Dp,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
modifier: Modifier = Modifier,
) {
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
// Tells a settled drop when this column has caught up with it.
LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, blocks)
}
// The tap detector outlives the composition that installed it — a pinch or a
// Settings change moves the hour height without restarting it — so it reads
// the height and the callback through state handles instead of capturing
// them, or taps land on the scale the column had before the zoom (#148).
val currentHourPx = rememberUpdatedState(hourPx)
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
Card(
// Plain rectangular column — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -581,31 +660,51 @@ private fun DayColumnCard(
// only fires on the column background. Snaps to the tapped hour.
.pointerInput(date) {
detectTapGestures { offset ->
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
onCreateAt(date, hour * 60)
currentOnCreateAt.value(
date,
tappedMinuteOfDay(offset.y, currentHourPx.value),
)
}
},
) {
val colWidth = maxWidth
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
// Keyed by event, so a block that changes time or lane is the *same*
// composable afterwards and tweens there. The ordinal disambiguates
// a column holding two occurrences of one series.
val ordinals = mutableMapOf<Long, Int>()
blocks.forEach { block ->
val laneWidth = colWidth / block.laneCount
val top = HOUR_HEIGHT * (block.startMin / 60f)
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
EventBlock(
block = block,
dark = dark,
onClick = { onEventClick(block.event) },
modifier = Modifier
.offset(x = laneWidth * block.lane, y = top)
.width(laneWidth)
.height(height)
.padding(horizontal = 1.dp),
)
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
key(block.event.eventId, ordinal) {
val laneWidth = colWidth / block.laneCount
val top = hourHeight * (block.startMin / 60f)
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
val place = animatedBlockPlacement(
x = laneWidth * block.lane,
y = top,
width = laneWidth,
height = height,
)
EventBlock(
block = block,
dark = dark,
height = place.height,
date = date,
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
modifier = Modifier
.offset(x = place.x, y = place.y)
.width(place.width)
.height(place.height)
.padding(horizontal = 1.dp),
)
}
}
// Current-time line, on top of the events, only on today's column.
if (date == today) {
NowLine(date = date, hourHeight = HOUR_HEIGHT)
NowLine(date = date, hourHeight = hourHeight)
}
}
}
@@ -615,7 +714,11 @@ private fun DayColumnCard(
private fun EventBlock(
block: TimedBlock,
dark: Boolean,
height: Dp,
date: LocalDate,
dragController: TimelineDragController,
onClick: () -> Unit,
onDrop: (TimelineDrop) -> Unit,
modifier: Modifier = Modifier,
) {
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
@@ -623,31 +726,67 @@ private fun EventBlock(
val locale = currentLocale()
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, use24Hour, locale)
val showTime = block.endMin - block.startMin >= 45
val density = LocalDensity.current
val titleLineHeight = with(density) {
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// What's left for text once the 2.dp top/bottom padding is paid for. A block
// that cannot afford both lines spends its space on the title, and one too
// short even for that drops the title rather than serving a sliced one.
val available = height - 4.dp
val showTime = block.endMin - block.startMin >= 45 &&
available >= titleLineHeight + timeLineHeight
val showTitle = available >= titleLineHeight
val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event)
// A block clipped at the top continues from the previous day: its top edge
// is midnight, not the event's start, so dragging it would invent a time.
val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone)
val dragModifier = rememberEventDragSource(
enabled = draggable,
key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel,
)
val lifted = draggable && dragController.ghosts(block, date)
val ghost = ghostAlpha(lifted)
Box(
modifier = modifier
// The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { contentDescription = "$title, $timeLabel" },
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
},
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f),
)
if (showTime) {
if (showTitle) {
Text(
text = timeLabel,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.6f),
color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined),
)
}
if (showTime) {
BlockTimeLabel(
label = timeLabel,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
)
}
}
@@ -656,17 +795,27 @@ private fun EventBlock(
@Composable
private fun DayLoading() {
val totalHeight = HOUR_HEIGHT * 24
val scale = LocalTimelineZoom.current.scale
val scrollState = rememberScrollState()
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
Spacer(Modifier.width(GUTTER_WIDTH))
Box(
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Same scale resolution as the loaded timeline, so the skeleton's column
// doesn't resize the moment the real day arrives.
val totalHeight = scale.hourHeight(maxHeight) * 24
Row(
modifier = Modifier
.weight(1f)
.height(totalHeight)
.padding(horizontal = 2.dp)
.background(MaterialTheme.colorScheme.surfaceContainer),
)
.fillMaxSize()
.padding(end = TIMELINE_CONTENT_END_INSET)
.verticalScroll(scrollState),
) {
Spacer(Modifier.width(GUTTER_WIDTH))
Box(
modifier = Modifier
.weight(1f)
.height(totalHeight)
.padding(horizontal = 2.dp)
.background(MaterialTheme.colorScheme.surfaceContainer),
)
}
}
}

View File

@@ -0,0 +1,65 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* A live, scaled-down Day view for the onboarding view chooser. Today's date is
* real; only the events are stand-ins.
*/
@Composable
internal fun DayViewPreview(
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) { sampleDayState(today, zone) }
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
// Half the scroll range is noon — the same centring the live view does.
scrollState.scrollTo(scrollState.maxValue / 2)
}
ScaledViewPreview(height = height, modifier = modifier) {
DaySuccess(
state = state,
topSectionColor = MaterialTheme.colorScheme.surface,
scrollState = scrollState,
allDayHeight = state.allDayStripHeight(),
dragController = rememberTimelineDragController(),
onEventClick = {},
onCreateAt = { _, _ -> },
onDrop = {},
)
}
}
/** Sample day state, laid out through the same helpers the live view uses. */
private fun sampleDayState(today: LocalDate, zone: TimeZone): DayUiState.Success {
val days = listOf(today)
val events = sampleTimelineEvents(days, today, zone)
return DayUiState.Success(
date = today,
today = today,
allDay = layoutAllDay(events.filter { it.isAllDay }, days, zone),
timed = layoutDay(events, today, zone),
)
}

View File

@@ -100,6 +100,7 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.floret.locale.currentLocale
@@ -386,7 +387,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
val instance = detail.instance
val dark = isSystemInDarkTheme()
val locale = currentDetailLocale()
val accent = eventFill(instance.color, dark, LocalSoftenColors.current)
val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
Column(
modifier = modifier

View File

@@ -48,9 +48,14 @@ class EventDetailViewModel @Inject constructor(
) : ViewModel() {
private val _target = MutableStateFlow<Target?>(null)
// Bumped by retry() to re-run the load for the same target.
// Bumped by retry() and by re-opening the target already shown, to re-run
// the load without changing the target.
private val _reload = MutableStateFlow(0)
// Last target whose content is already on screen; a re-read of it skips the
// Loading skeleton so the sheet doesn't blank out between two identical reads.
private var loadedTarget: Target? = null
private val _deleteState = MutableStateFlow<DeleteUiState>(DeleteUiState.Idle)
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
@@ -58,11 +63,14 @@ class EventDetailViewModel @Inject constructor(
combine(_target, _reload) { target, _ -> target }
.flatMapLatest { target ->
if (target == null) {
loadedTarget = null
flowOf<EventDetailUiState>(EventDetailUiState.Loading)
} else {
flow {
emit(EventDetailUiState.Loading)
emit(loadDetail(target))
if (loadedTarget != target) emit(EventDetailUiState.Loading)
val loaded = loadDetail(target)
loadedTarget = target.takeIf { loaded is EventDetailUiState.Success }
emit(loaded)
}
}
}
@@ -78,9 +86,15 @@ class EventDetailViewModel @Inject constructor(
* the occurrence's own times (from `CalendarContract.Instances`); they
* override the series DTSTART/DTEND so recurring events show the correct
* date instead of the first occurrence.
*
* Re-opening the *same* occurrence always re-reads it. The view model
* outlives the sheet, and a `StateFlow` conflates an identical value away,
* so assigning the target alone would leave an edit that changed no time —
* adding a description, say (#196) — showing the state from before the save.
*/
fun open(eventId: Long, beginMillis: Long, endMillis: Long) {
_target.value = Target(eventId, beginMillis, endMillis)
val target = Target(eventId, beginMillis, endMillis)
if (_target.value == target) _reload.value += 1 else _target.value = target
}
/** Re-run the current load after a failure. */

View File

@@ -127,12 +127,14 @@ import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
@@ -451,30 +453,11 @@ private fun SaveScopeDialog(
onSelect: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_recurring_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (!recurrenceChanged) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
)
}
OptionCard(
label = stringResource(R.string.event_delete_option_following),
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { onSelect(RecurringWriteScope.AllEvents) },
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
RecurringScopeDialog(
title = stringResource(R.string.event_edit_recurring_title),
onSelect = onSelect,
onDismiss = onDismiss,
allowOccurrence = !recurrenceChanged,
)
}
@@ -515,8 +498,8 @@ private fun EventEditContent(
// they're locked here; everything else (reminders, location, notes) is the
// user's to edit.
val locked = state.isManaged
// Read in the form's own window, not the picker's: the field holding focus
// lives here, so this is the controller that can put its keyboard away.
// Read in the form's own window, not the picker's: the focused field lives
// here, so this is the controller that can put its keyboard away.
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
var picker by remember { mutableStateOf<PickerTarget?>(null) }
@@ -563,7 +546,7 @@ private fun EventEditContent(
// The accent ties the form to the detail screen's design language: the
// bar under the title takes the target calendar's colour.
val soften = LocalSoftenColors.current
val accent = selectedCalendar?.let { eventFill(it.color, dark, soften) }
val accent = selectedCalendar?.let { eventAccent(it.color, dark, soften) }
?: MaterialTheme.colorScheme.primary
val gap = 12.dp
@@ -1124,10 +1107,8 @@ private fun EventEditContent(
null -> Unit
}
// A full-screen picker over the form is a change of place, so the form's
// keyboard has no business following it there — least of all onto the
// calendar manager, which the picker can hand off to. The form's own field
// keeps its text; only focus and the IME go.
// A full-screen picker is a change of place, so the keyboard shouldn't
// follow it. The field keeps its text; only focus and the IME go.
LaunchedEffect(showCalendarPicker) {
if (showCalendarPicker) {
focusManager.clearFocus(force = true)
@@ -1143,11 +1124,9 @@ private fun EventEditContent(
viewModel.setCalendar(it)
showCalendarPicker = false
},
// Close the picker on the way out. It is a Compose Dialog its own
// window, always above the activity's content so the manager would
// otherwise open behind it and the tap would look dead. The form
// stays standing underneath, its calendar row one tap from a picker
// that re-queries on open.
// Close the picker first: it is a Compose Dialog in its own window,
// always above the activity's content, so the manager would
// otherwise open behind it and the tap would look dead.
onManageCalendars = onManageCalendars?.let { openManager ->
{
showCalendarPicker = false
@@ -1344,10 +1323,7 @@ private enum class RecurrenceEndMode { Never, Until, Count }
*
* Both steps show the rule as *dates* as well as words: every preset row
* carries the next few occurrences it would produce from [startDate], and the
* custom step repeats that under its live read-out. It is where a rule most
* easily means something other than it sounds like — "monthly" on the 31st
* skips February, an every-other-week rule lands a fortnight from the start's
* own week — and only the dates say so.
* custom step repeats that under its live read-out.
*/
@Composable
private fun RecurrencePickerDialog(
@@ -1409,9 +1385,8 @@ private fun RecurrencePickerDialog(
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) }
}
// Kept as the rule object, not just its RRULE text: the read-out renders the
// string, the date list expands the rule, and both must describe the one
// thing OK would save.
// Kept as the rule object, not just its RRULE text: the read-out and the
// date list must describe the one thing OK would save.
val customRule: SimpleRecurrence? = if (interval != null && customEnd != null) {
SimpleRecurrence(
freq = freq,
@@ -1505,9 +1480,8 @@ private fun RecurrencePickerDialog(
.padding(horizontal = 16.dp),
)
// The same rule as dates. Rendered even while the form is
// incomplete (as an empty line) so the controls below don't
// shift as it comes and goes.
// Rendered even while incomplete (as an empty line), so the
// controls below don't shift as it comes and goes.
Text(
text = customRule?.let { nextOccurrencesText(it, startDate, locale) }.orEmpty(),
style = MaterialTheme.typography.bodyMedium,
@@ -2034,10 +2008,8 @@ private fun readContactAddress(context: Context, uri: Uri): String? =
* Visibility selector: one card per level, each with its own icon; the
* current level is highlighted. Tap picks and closes.
*
* Every level carries a line saying who this affects, because the four words
* alone don't: visibility is about what *other people on a shared calendar*
* see, which is exactly the part the label leaves out — and "Confidential" has
* no meaning at all until you know the server decides what it does with it.
* Every level carries a line saying who this affects — the labels alone don't
* say that visibility is about what others on a shared calendar see.
*/
@Composable
private fun VisibilityPickerDialog(

View File

@@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
@@ -51,28 +52,38 @@ import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
import javax.inject.Inject
/**
* Where a prefilled [EventEditViewModel.openImported] form came from — the two
* sources want different reminder handling (#49).
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
* want different reminder handling (#49), and differ in whether they own the
* event's length ([ownsDuration], #54) — one that does is never restretched to the
* default-duration setting.
*/
enum class ImportSource {
enum class ImportSource(internal val ownsDuration: Boolean) {
/**
* An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps).
* It carries no reminders of its own, so the settings default is applied
* automatically, exactly like an in-app new event.
* An external `ACTION_INSERT` intent that named both ends (another app/widget,
* e.g. Google Maps), or an in-app duplicate. It carries no reminders of its
* own, so the settings default is applied automatically, exactly like an
* in-app new event; its span, though, is the caller's.
*/
Insert,
Insert(ownsDuration = true),
/**
* An `ACTION_INSERT` that named only a start. Reminders behave as for
* [Insert], but the length is ours to pick, so the default-duration setting
* applies just like an in-app new event.
*/
InsertOpenEnded(ownsDuration = false),
/**
* A parsed single-event `.ics` file. Its own reminders are respected; the
* settings default is offered through [EventEditViewModel.importReminderPrompt]
* rather than silently applied or suppressed.
* rather than silently applied or suppressed. Its span is the file's.
*/
File,
File(ownsDuration = true),
}
/**
@@ -113,6 +124,9 @@ class EventEditViewModel @Inject constructor(
// freezes the auto-applied default: switching calendars no longer overwrites
// their choice. Reset with the form.
private val _remindersTouched = MutableStateFlow(false)
// Same freeze for the default duration (#54): once the user has set an end
// time by hand, switching calendars no longer stretches the event.
private val _durationTouched = MutableStateFlow(false)
// A one-time offer, raised when a .ics import opens, to replace the file's
// reminders with the settings default (#49). Null while there's nothing to ask.
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
@@ -233,11 +247,9 @@ class EventEditViewModel @Inject constructor(
// off the calendar's durable marker, not a stored id, so it holds after a
// backup restore too.
val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true
// The picker offers writable calendars only; the event's own calendar is
// added back whenever it isn't among them — a managed special-dates one,
// or one switched off on this device — so the row keeps naming it instead
// of reading as the "no calendar" error, and saving can leave the event
// where it is. A calendar the app may not write to is still no target.
// The event's own calendar is added back whenever it isn't a target
// (managed, or switched off), so the row keeps naming it and saving can
// leave the event where it is. A read-only one is still no target.
val ownCalendar = resolvedCalendar?.takeIf { own ->
own.canModifyContents && external.writable.none { it.id == own.id }
}
@@ -284,8 +296,10 @@ class EventEditViewModel @Inject constructor(
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
* from midnight) anchors the start when the form is opened by tapping a slot
* in the day/week grid; without it the default is the next full hour (today)
* or 09:00 (any other day). No-op when a form is already open, so user input
* survives configuration changes; [reset] clears it when the screen closes.
* or 09:00 (any other day). The event opens an hour long and is stretched to
* the configured default by [applyDefaultDuration], like the reminders.
* No-op when a form is already open, so user input survives configuration
* changes; [reset] clears it when the screen closes.
*/
fun openNew(date: LocalDate, startMinutes: Int? = null) {
if (_form.value != null) return
@@ -302,9 +316,11 @@ class EventEditViewModel @Inject constructor(
}
else -> LocalDateTime(date, LocalTime(9, 0))
}
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
val end = (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes)
.toLocalDateTime(zone)
_form.value = EventForm(calendarId = null, start = start, end = end)
applyDefaultReminder()
applyDefaultDuration()
}
/**
@@ -323,6 +339,12 @@ class EventEditViewModel @Inject constructor(
* settings default is configured and differs, [importReminderPrompt] offers
* to swap it in rather than silently deciding for the user.
*
* The length is frozen the same way: a source that named the event's end
* ([ImportSource.ownsDuration]) keeps it, so the default-duration setting
* never silently rewrites a span someone else chose; only an
* [ImportSource.InsertOpenEnded] intent — a start with no end — gets the
* default applied.
*
* No-op when a form is already open, so the prefill survives configuration
* changes.
*/
@@ -330,8 +352,9 @@ class EventEditViewModel @Inject constructor(
if (_form.value != null || _editTarget.value != null) return
_revealed.value = form.populatedFields()
_form.value = form
if (source.ownsDuration) _durationTouched.value = true else applyDefaultDuration()
when (source) {
ImportSource.Insert ->
ImportSource.Insert, ImportSource.InsertOpenEnded ->
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
else applyDefaultReminder()
@@ -360,7 +383,9 @@ class EventEditViewModel @Inject constructor(
val form = _form.value ?: return@launch
if (_editTarget.value != null || _remindersTouched.value) return@launch
val reminders = defaults.resolveFor(targetId, form.isAllDay)
_form.value = form.copy(reminders = reminders)
// Write through update(): the duration default resolves in parallel,
// so both must compose onto the current form, not a pre-suspend copy.
update { it.copy(reminders = reminders) }
// Surface the section so an auto-applied default is visible and
// removable, even when Reminders isn't a default-shown field.
if (reminders.isNotEmpty()) {
@@ -379,6 +404,39 @@ class EventEditViewModel @Inject constructor(
ReminderDefaults(timed, allDay, timedOv, allDayOv)
}.first()
/**
* Stretch a new timed event to the configured default length (#54) — the
* resolved calendar's per-calendar duration, otherwise the global default —
* keeping its start put. No-op while editing an existing event, on an all-day
* event (which is date-anchored), or once the user has set an end time by
* hand. [calendarId] short-circuits the resolution after a calendar switch;
* null resolves it as the form does.
*/
private fun applyDefaultDuration(calendarId: Long? = null) {
if (_editTarget.value != null || _durationTouched.value) return
viewModelScope.launch {
val global = settingsPrefs.defaultEventDurationMinutes.first()
val overrides = settingsPrefs.perCalendarEventDuration.first()
// Only resolve the calendar when an override could actually apply —
// that read waits on the provider query, and the stretch would then
// land after the user can already see (and accept) the end time.
val targetId = when {
overrides.isEmpty() -> null
else -> calendarId ?: resolvedCalendarId.first()
}
// Re-check after suspending: bail if the form closed or the user edited.
if (_editTarget.value != null || _durationTouched.value) return@launch
if (_form.value?.isAllDay != false) return@launch
val duration = resolveDefaultEventDuration(global, overrides, targetId)
val zone = TimeZone.currentSystemDefault()
update { form ->
form.copy(
end = (form.start.toInstant(zone) + duration.minutes).toLocalDateTime(zone),
)
}
}
}
/**
* A `.ics` import respects the file's reminders, but an event opened from a
* file often has none while the user still expects their configured default.
@@ -457,6 +515,7 @@ class EventEditViewModel @Inject constructor(
_editTarget.value = null
_loadFailed.value = false
_remindersTouched.value = false
_durationTouched.value = false
_importReminderPrompt.value = null
}
@@ -481,6 +540,9 @@ class EventEditViewModel @Inject constructor(
// The default reminder differs for all-day vs timed; re-apply the
// type-appropriate default unless the user has hand-edited it (guarded).
applyDefaultReminder()
// Coming back out of all-day re-applies the default length (guarded);
// going all-day is a no-op, since dates carry no duration.
applyDefaultDuration()
}
/**
@@ -502,9 +564,10 @@ class EventEditViewModel @Inject constructor(
*/
fun setCalendar(id: Long) {
update { it.copy(calendarId = id, colorKey = null, color = null) }
// A fresh event re-inherits the new calendar's default reminder unless
// the user has already hand-edited it (guarded inside).
// A fresh event re-inherits the new calendar's default reminder and
// length unless the user has already hand-edited them (guarded inside).
applyDefaultReminder(id)
applyDefaultDuration(id)
}
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
@@ -561,8 +624,18 @@ class EventEditViewModel @Inject constructor(
/** Moving the start drags the end along, preserving the duration. */
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
fun setEndDate(date: LocalDate) = update { it.copy(end = LocalDateTime(date, it.end.time)) }
fun setEndTime(time: LocalTime) = update { it.copy(end = LocalDateTime(it.end.date, time)) }
// Setting an end by hand is the user picking a length: it freezes the
// default duration, so a later calendar switch keeps their span (#54).
fun setEndDate(date: LocalDate) {
_durationTouched.value = true
update { it.copy(end = LocalDateTime(date, it.end.time)) }
}
fun setEndTime(time: LocalTime) {
_durationTouched.value = true
update { it.copy(end = LocalDateTime(it.end.date, time)) }
}
/**
* Validate and write. Saving a dirty recurring event pauses in

View File

@@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.sourceAppName
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.positionOf
@@ -73,7 +74,12 @@ private fun FilterList(
},
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 12.dp,
bottom = 4.dp,
),
)
group.calendars.forEachIndexed { index, cal ->
GroupedRow(
@@ -103,7 +109,7 @@ private fun FilterLoading(modifier: Modifier = Modifier) {
repeat(4) {
Box(
modifier = Modifier
.padding(horizontal = 28.dp)
.padding(horizontal = GroupedListInset)
.fillMaxWidth()
.height(36.dp)
.background(
@@ -129,6 +135,6 @@ private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier)
textAlign = TextAlign.Center,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 28.dp, vertical = 24.dp),
.padding(horizontal = GroupedListInset, vertical = 24.dp),
)
}

View File

@@ -32,9 +32,8 @@ class FilterViewModel @Inject constructor(
repository.calendars(),
prefs.hiddenCalendarIds,
) { calendars, hidden ->
// Calendars switched off in Settings → Calendars are off device-wide
// and don't belong in the drawer's hide/show list (you can't hide
// what is already off). They live only in Settings → Calendars.
// Calendars switched off device-wide don't belong in the drawer's
// hide/show list; they live only in Settings → Calendars.
val enabled = calendars.filter { it.isVisibleInSystem }
if (enabled.isEmpty()) {
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)

View File

@@ -175,10 +175,8 @@ private fun ManyContent(
onSelect: (Long) -> Unit,
onManageCalendars: (() -> Unit)? = null,
) {
// No calendar to import into — tell the user honestly, and carry the same
// way out the picker's footer offers below. This is the state that footer
// exists for: every writable calendar being switched off, read-only or
// contact-filled is exactly what empties this list (#76).
// No calendar to import into; carries the same way out the picker's footer
// offers below (#76).
if (state.calendars.isEmpty()) {
CenteredMessage(
message = stringResource(R.string.import_no_calendar),

Some files were not shown because too many files have changed in this diff Show More