Compare commits

...
Author SHA1 Message Date
makiolaj 754745586d Run the Play job on trixie for fastlane's Ruby 3.2 requirement (#509) 2026-11-13 19:17:46 +01:00
Jean-Luc Makiola 1a1df07dc3 Release v2.24.2 (#511)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 12s
Renovate / renovate (push) Successful in 1m53s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 17m38s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 34s
- Queued drops: moves made while an earlier one is still saving are queued instead of dropped, with messages for stale and skipped moves (#494)
- Day view title names the month (#501)
- Dependency updates: kotlinx-datetime, AppCompat, fastlane, Renovate image (#507–#510)
- Version 2.24.2 (2240299), CHANGELOG, What's New in all store locales

#494 stays open until the reporter answers; the fix covers the ways a quick move could get lost, the original report isn't reproduced.

Refs #494
Closes #501
2026-11-13 18:56:38 +01:00
makiolaj 75315d92a7 Merge main into release/2.24.2
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-13 16:10:53 +01:00
makiolaj e9cf7d66ba Release v2.24.2
Beta — Codeberg pre-release / detect (push) Successful in 4s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-13 15:51:08 +01:00
Jean-Luc Makiola e01d6efb84 Merge pull request 'chore(deps): update ghcr.io/renovatebot/renovate docker tag to v44' (#510) from deps/renovate-44 into release/2.24.2 2026-11-13 15:43:14 +01:00
Jean-Luc Makiola b7c801c8db Merge pull request 'chore(deps): update dependency fastlane to v2.241.1' (#509) from deps/fastlane into release/2.24.2 2026-11-13 15:42:26 +01:00
Jean-Luc Makiola b15e90cc64 Merge pull request 'fix(deps): update appcompat to v1.8.0' (#508) from deps/appcompat into release/2.24.2 2026-11-13 15:17:14 +01:00
Jean-Luc Makiola 8d3be6aba3 Merge pull request 'fix(deps): update kotlinxdatetime to v0.8.0-0.6.x-compat' (#507) from deps/kotlinxdatetime into release/2.24.2 2026-11-13 15:14:40 +01:00
renovate-bot 464ae8952e chore(deps): update ghcr.io/renovatebot/renovate docker tag to v44 2026-11-13 14:13:53 +01:00
renovate-bot 2007541adf chore(deps): update dependency fastlane to v2.241.1 2026-11-13 14:13:53 +01:00
renovate-bot d6167617a9 fix(deps): update appcompat to v1.8.0 2026-11-13 14:13:53 +01:00
renovate-bot 38d7c1494d fix(deps): update kotlinxdatetime to v0.8.0-0.6.x-compat 2026-11-13 14:13:53 +01:00
Jean-Luc Makiola c0c0d56e86 Merge pull request 'Queue drops instead of silently dropping them (#494)' (#506) from fix/494-queued-drop into release/2.24.2 2026-11-13 14:09:14 +01:00
Jean-Luc Makiolaandmakiolaj 745301d14b Day view: title names the month (#501) (#505)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
The day view's top bar now shows the month name (e.g. "November", abbreviated when space is short, with the year outside the current year), the same as the week and 3-day views. Weekday and date already sit in the column header right below, so the old title repeated them.

Closes #501

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/505
2026-11-13 13:37:38 +01:00
makiolaj 8d640b595e Third review round: undo by drop, queued chip messages, recent-write stale check, held strip and seat (#494) 2026-11-13 13:30:49 +01:00
makiolaj 1925e1c2f4 Hold every dropped copy until its own write lands, cancel drops behind a dialog, keep the first Undo (#494) 2026-11-13 13:18:11 +01:00
makiolaj 0595124ecd Drops follow their event if it moved, failures stay on the chip (#494) 2026-11-13 12:45:48 +01:00
makiolaj 8e3202d1ad Give every drop its own token and queue drops in order (#494) 2026-11-13 12:00:37 +01:00
makiolaj 5f8eb39206 Queue a drop made while another move is still saving (#494) 2026-11-13 11:27:46 +01:00
95ad3a30cf Release v2.24.1 (#504)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Renovate / renovate (push) Successful in 1m20s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 19m27s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m58s
- Agenda widget: Cards / Classic style option, opacity slider for all Solid/Tinted widgets (#489)
- Month widget fits events into short week rows (#491)
- Week numbers follow the locale's rules and the row's first day (#493)
- Readable dimmed events in dark widgets, pinned Light/Dark theme matches system colours (#475)
- Dropped unused WorkManager permissions: network state, wake lock, foreground service (#448)
- Weblate translations (#434), incl. the Arabic string from after the 2.24.0 squash
- Version 2.24.1 (2240199), CHANGELOG, What's New in all store locales

#489 deviates from the issue: the classic look comes back as a style option instead of replacing the new design.

Closes #489
Closes #491
Closes #493
Closes #475
Closes #448

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Louis Leblanc <sandraev@posteo.dk>
Co-authored-by: Dmitri Gabinski <dkgabi@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/504
2026-11-13 11:17:48 +01:00
Jean-Luc Makiola 8ce4831efa Translations update from Weblate (#434)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 12s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 2m59s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/434
2026-11-13 11:07:42 +01:00
makiolaj e7bf700f36 Release v2.24.1
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-12 15:57:47 +01:00
makiolaj c10342818a Merge Weblate translations (#434) 2026-11-12 15:56:26 +01:00
Weblate 0f29dc4db6 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-11-12 14:52:53 +00:00
Anonymous 80fae240da Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 3.9% (27 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/zh_Hant/
2026-11-12 14:52:48 +00:00
Anonymous ab2e11a2e4 Translated using Weblate (Dutch)
Currently translated at 100.0% (678 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/nl/
2026-11-12 14:52:38 +00:00
Anonymous fb4fd17603 Translated using Weblate (Hungarian)
Currently translated at 9.2% (63 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/hu/
2026-11-12 14:52:28 +00:00
Anonymous 86f7637213 Translated using Weblate (Slovak)
Currently translated at 36.8% (250 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/sk/
2026-11-12 14:52:17 +00:00
Anonymous c5084b86af Translated using Weblate (Belarusian)
Currently translated at 93.2% (632 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/be/
2026-11-12 14:52:11 +00:00
Dmitri Gabinski 919b95c378 Translated using Weblate (Belarusian)
Currently translated at 93.2% (632 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/be/
2026-11-12 14:52:10 +00:00
Anonymous ec7db10601 Translated using Weblate (Czech)
Currently translated at 100.0% (678 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-11-12 14:52:01 +00:00
Anonymous 73403ee799 Translated using Weblate (Japanese)
Currently translated at 17.1% (116 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-11-12 14:51:50 +00:00
Anonymous a294542eda Translated using Weblate (Portuguese (Brazil))
Currently translated at 84.0% (570 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pt_BR/
2026-11-12 14:51:35 +00:00
Anonymous 786bde2965 Translated using Weblate (Arabic)
Currently translated at 100.0% (678 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-11-12 14:51:33 +00:00
Sup99999 e2ac8bbdc7 Translated using Weblate (Arabic)
Currently translated at 100.0% (678 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-11-12 14:51:33 +00:00
Anonymous 673cfc2f99 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 83.6% (567 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/zh_Hans/
2026-11-12 14:51:23 +00:00
Anonymous c533cc0439 Translated using Weblate (Russian)
Currently translated at 37.7% (256 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ru/
2026-11-12 14:51:12 +00:00
Anonymous 7a49dffe0d Translated using Weblate (Portuguese)
Currently translated at 67.1% (455 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pt/
2026-11-12 14:51:05 +00:00
Anonymous e6ab85d382 Translated using Weblate (Polish)
Currently translated at 83.6% (567 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-11-12 14:50:55 +00:00
Anonymous 011356e103 Translated using Weblate (Italian)
Currently translated at 100.0% (678 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-11-12 14:50:45 +00:00
Anonymous c8595ccedd Translated using Weblate (French)
Currently translated at 87.0% (590 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/fr/
2026-11-12 14:50:33 +00:00
Louis Leblanc 9e1fd2956d Translated using Weblate (French)
Currently translated at 87.0% (590 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/fr/
2026-11-12 14:50:32 +00:00
Anonymous 6a1697dc5a Translated using Weblate (Spanish)
Currently translated at 61.5% (417 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/es/
2026-11-12 14:50:20 +00:00
Anonymous 2641145b60 Translated using Weblate (German)
Currently translated at 100.0% (678 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-11-12 14:50:15 +00:00
Sup99999 70a7bf1033 Translated using Weblate (Arabic)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Currently translated at 100.0% (678 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-11-12 15:47:40 +01:00
Jean-Luc Makiola a92dd013bc Merge pull request 'Widgets: readable dimmed events, pinned theme matches system (#475)' (#500) from fix/475-widget-feedback into release/2.24.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-09 21:06:55 +01:00
makiolaj 6d6baf3f35 Merge remote-tracking branch 'origin/release/2.24.1' into fix/475-widget-feedback
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt
#	floret-kit
2026-11-09 21:05:08 +01:00
Jean-Luc Makiola 72f2b33754 Merge pull request 'Classic agenda widget style and widget opacity (#489)' (#499) from feat/489-classic-agenda into release/2.24.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-09 21:03:28 +01:00
makiolaj 89a456f88c Merge remote-tracking branch 'origin/release/2.24.1' into feat/489-classic-agenda
# Conflicts:
#	floret-kit
2026-11-09 21:00:56 +01:00
Jean-Luc Makiola d98d995326 Merge pull request 'Month widget fits events into short weeks (#491)' (#498) from fix/491-month-widget-lanes into release/2.24.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-09 20:59:40 +01:00
makiolaj 20a76f1921 Merge remote-tracking branch 'origin/release/2.24.1' into fix/491-month-widget-lanes 2026-11-09 20:57:47 +01:00
Jean-Luc Makiola 0c4a7992a7 Merge pull request 'Week numbers follow the locale and the row's first day (#493)' (#497) from fix/493-week-number into release/2.24.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-09 20:57:05 +01:00
makiolaj 5e6b88fa31 Bump floret-kit to main (#493) 2026-11-09 20:55:04 +01:00
makiolaj d8735cc738 Merge remote-tracking branch 'origin/release/2.24.1' into fix/493-week-number 2026-11-09 20:54:51 +01:00
Jean-Luc Makiola 185671adb5 Merge pull request 'Drop unused WorkManager permissions (#448)' (#496) from fix/448-library-permissions into release/2.24.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-09 20:54:14 +01:00
makiolaj ee268444b7 Widgets: readable dimmed events, pinned theme matches system (#475) 2026-11-09 20:41:20 +01:00
makiolaj 9387bdc1ee Drop unused WorkManager permissions (#448) 2026-11-09 20:32:00 +01:00
makiolaj 7cfdb43f37 Widget config: readability info as a popover beside Opacity (#489) 2026-11-09 20:18:14 +01:00
makiolaj 06f658917f Classic agenda: invert text colour instead of backings at low opacity (#489) 2026-11-09 20:07:18 +01:00
makiolaj 88d5614158 Bump floret-kit: inset opacity slider row (#489) 2026-11-09 19:58:15 +01:00
makiolaj f896153fb7 Classic agenda: readable dimming on any fill, year on far dates (#489) 2026-11-09 19:27:27 +01:00
makiolaj 1004356e75 Month widget: day totals from countByDay, test split bars (#491) 2026-11-09 19:24:57 +01:00
makiolaj 77b0c6db09 Week numbers follow the device region, shared helper (#493) 2026-11-09 19:22:55 +01:00
makiolaj dec2d1cb05 Month widget: one lane shows bars and per-day counts (#491) 2026-11-09 19:17:57 +01:00
makiolaj 76314aebbd Number weeks by the locale's rules (#493) 2026-11-09 19:15:00 +01:00
makiolaj 7d149d2ab1 Classic agenda: back rows when the fill is see-through (#489) 2026-11-09 19:11:54 +01:00
makiolaj ff4082d5df Classic agenda: line day labels up with the card rows (#489) 2026-11-09 19:02:48 +01:00
makiolaj 51f58e76b2 Classic agenda: seamless day cards, aligned placeholder, shared day action (#489) 2026-11-09 18:59:02 +01:00
makiolaj 347ce74808 Classic agenda: single cap, card inset, shared dim rule (#489) 2026-11-09 18:52:00 +01:00
makiolaj 726dbbbaf3 Month widget: require font scale for lane fitting (#491) 2026-11-09 18:51:00 +01:00
makiolaj fb434f3f15 Month widget: compact-only bar fallback, no compact lane above default font scale (#491) 2026-11-09 18:48:14 +01:00
makiolaj aacd86cebe Classic agenda: cap with labels, visible dimming, solid day cards (#489) 2026-11-09 18:43:56 +01:00
makiolaj bd3b4605cb Fix week number gutter KDoc (#493) 2026-11-09 18:41:40 +01:00
makiolaj f267744c1c Month widget: keep the single lane's bars, compact lane only at normal font scale (#491) 2026-11-09 18:40:30 +01:00
makiolaj fffb033e39 Agenda widget: classic style and background opacity (#489) 2026-11-09 18:31:12 +01:00
makiolaj f574f039f5 Month widget: keep a lane in short week rows (#491) 2026-11-09 18:24:44 +01:00
makiolaj 506ebdb092 Number week rows by their Thursday (#493) 2026-11-09 18:21:56 +01:00
+3 9ccc303a2a Release v2.24.0 (#487)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Renovate / renovate (push) Successful in 2m19s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 20m28s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m34s
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
2.24.0: the widget redesign and the fixes collected on release/2.24.0. Full list in CHANGELOG.md under 2.24.0.

- Seven new widgets (quick add, date, week strip, upcoming, today, countdown, month with day), each with its own settings; new look for the agenda and month widgets.
- Drag all-day events between days or into the grid; cancelled and tentative events are marked; month view setting to hide start times.
- Fixes: short events late at night, one notification per event, weekend missing from the week strip widget, split month view animations and back gesture.
- Weblate translations from #434, including the new Traditional Chinese (zh-TW), with its store listing and What's New.

Merges main's v2.23.1 in. #427 is moved out of the milestone until it can be reproduced.

Closes #434

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Benutzer <gabrielegobboch@protonmail.ch>
Co-authored-by: N Donkersloot <n@donkerslootjes.nl>
Co-authored-by: VinCap <vcapaldo@ik.me>
Co-authored-by: AventineC <AventineC@outlook.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: NattyNarwhal <fr.schuessele@gmx.de>
Co-authored-by: James Trivette <trivette@atlas.cz>
Co-authored-by: Dmitri Gabinski <dkgabi@gmail.com>
Co-authored-by: Tritra002 <tritratrulala002@outlook.com>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/487
2026-11-06 17:49:13 +01:00
Jean-Luc Makiolaandmakiolaj a5c4a73de1 Release v2.23.1 (#453)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Renovate / renovate (push) Successful in 2m2s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 17m11s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m37s
Follow-up to the #142 feedback in the split month view:

- With a day selected, the filter chip now stands in for the day's header, so the date isn't shown twice. Tapping the chip opens the Day view; its ✕ clears the filter.
- The day headers in the agenda pane below the grid use less vertical space.
- Event dots no longer drop on days that show a "+N".

Closes #142

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/453
2026-10-28 20:11:10 +01:00
295 changed files with 13475 additions and 2599 deletions

No files matched your search

+4 -1
View File
@@ -318,13 +318,16 @@ jobs:
# not provide node inside custom job containers, so JavaScript actions # not provide node inside custom job containers, so JavaScript actions
# (checkout, download-artifact) can't run there. The Renovate job gets away # (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 # 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. # the same way sshpass, jq and fdroidserver are in the job above, from trixie:
# fastlane >= 2.241 needs Ruby 3.2+, and bookworm's apt only has 3.1.
play: play:
needs: [detect, release] needs: [detect, release]
# workflow_dispatch is the F-Droid re-sign recovery path — it must never # workflow_dispatch is the F-Droid re-sign recovery path — it must never
# touch Play, so gate on a real release only. # touch Play, so gate on a real release only.
if: needs.detect.outputs.is_release == 'true' if: needs.detect.outputs.is_release == 'true'
runs-on: docker runs-on: docker
container:
image: node:22-trixie
env: env:
VERSION: ${{ needs.detect.outputs.version }} VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }} VERSION_CODE: ${{ needs.detect.outputs.version_code }}
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
# directly drops the docker-in-docker requirement entirely. # directly drops the docker-in-docker requirement entirely.
# Full tag pinned; Renovate's github-actions manager keeps it bumped. # Full tag pinned; Renovate's github-actions manager keeps it bumped.
container: container:
image: ghcr.io/renovatebot/renovate:43.232.0 image: ghcr.io/renovatebot/renovate:44.237.3
steps: steps:
- name: Run Renovate - name: Run Renovate
run: renovate run: renovate
+96
View File
@@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [2.24.2] — 2026-11-13
### Fixed
- **Quick moves no longer get lost.** Dropping an event while an earlier move was still saving used to snap it back without a word. Drops now wait in line and are saved in order, and a moved event stays where you dropped it until the calendar shows it there. If an event changed before a drop could be saved, or the line is full, a message says so instead ([#494]).
- **Undo after a run of moves** keeps the first move's Undo, and each failed move gets its own message rather than replacing the last one ([#494]).
### Changed
- **The day view's title names the month**, like the week and 3-day views. Weekday and date already sit in the column header below it ([#501]).
- Library updates (kotlinx-datetime, AppCompat).
## [2.24.1] — 2026-11-12
### Added
- **A classic style for the agenda widget.** Its settings now have a Style option: Cards keeps the new look, Classic brings back the 2.23 layout with a line per day and a colour stripe beside each event, under the current header. Dates in another year show the year. Classic has an Invert text colour switch, so its text stays readable on light and dark wallpapers ([#489]).
- **Widget opacity.** Every widget with a Solid or Tinted background has an Opacity slider from 20 to 100%. Below 50%, the card-style widgets back their content like a Transparent widget so it stays readable ([#489]).
### Fixed
- **Week numbers match your region.** With weeks starting on Sunday, the month and week views showed every week number one too low. Rows are now numbered by your region's rules, so ISO regions such as Germany are unchanged ([#493]).
- **The month widget shows events at every height.** At some sizes a week row had room for a little more than the date but not a full line of events, and drew none. It now fits a slimmer line there, and a row with a single line shows "+N" on days with more events than fit ([#491]).
- **Dimmed past events in dark widgets are readable again.** They used to turn pastel with white text on top ([#475]).
- **A widget set to Light or Dark matches the system colours.** With Material You, the + button and today's date could look different from a widget that follows the app ([#475]).
### Changed
- **Fewer permissions.** Calendula no longer asks for network state, wake lock or foreground service access, which a library added without the app needing them ([#448]).
- Translation updates from [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
## [2.24.0] — 2026-11-06
### Added
- **Six new home-screen widgets.** Quick add (1×1) opens a new event on today with the same defaults as the app's + button ([#435]). Date (1×1) shows today's weekday and day number on a rounded shape and opens today ([#376]). Week strip (4×1) shows this week's days with a dot in each event's colour and today filled ([#436]). Upcoming (2×2) shows today's date over your next events, as many as fit ([#437]). Today lists today's events; once they're over it says "Nothing left today" and shows tomorrow's first one or two ([#438]). Countdown counts down to your next timed event, or to an event or series you pick, like a holiday in 12 days ([#439]).
- **A month and day widget.** A compact month grid sits beside the events of one day. Tap a day in the grid to see its events, and tap the list to open that day in the app ([#441]).
- **Each widget has its own settings.** Placing a widget, or long-pressing it and choosing Edit, opens a settings screen with a live preview. Pick a solid, tinted or transparent background, a light or dark theme or the app's, and which calendars it shows. Widgets that list events also have their time range, text size and how past events look. Settings → Widgets lists the widgets on your home screen so you can change them from there. These options used to be shared by all widgets; widgets you already placed keep what you had set ([#292]).
- **Turn off start times in the month view.** Settings → Views → Month → Start times hides the time in front of event titles, so narrow fonts keep more of the title. With it on, the time now only shows when the whole title, or at least its first 12 characters, still fits beside it ([#476]).
- **Drag all-day events.** In the day, 3-day and week views, long-press an event in the all-day strip and move it sideways to another day, or down into the grid to turn it into a timed event ([#296]).
- **Traditional Chinese**, started as a community translation through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/). It covers only a few screens so far, so the rest still shows in English until it fills out. Pick it under Settings → Language. Thanks to AventineC.
### Changed
- **The agenda and month widgets have a new look.** The agenda puts each day in its own card, with a round date pill beside the first event and today's pill filled. The month widget has rounded day cells, weekday pills, shaded days outside the month and a + for a new event. The agenda's refresh button is gone, since widgets now update on their own ([#71], [#90]).
- **Tapping a day in the month widget opens that day,** wherever in its cell you tap ([#386]).
- **The widget picker shows what each widget looks like.** On Android 15 and later the previews are the real widgets with sample events, in your language and colours; on older versions they are redrawn to match.
- **Widgets are listed by their name alone,** without "Calendula" in front, and each has a short description in the picker ([#71]).
- **Past events in widgets stay readable when dimmed.** Their background fades further and their text less ([#90]).
- **Events from midnight to midnight show as all-day events.** A timed event that starts and ends at midnight now sits in the all-day strip, shows as "All day" in the agenda and widgets, and its reminder names the day instead of 00:00 – 00:00. The event itself stays as it was saved ([#296]).
- **Cancelled and tentative events are marked.** Cancelled events are struck through and listed last, like declined invites, and tentative ones get the outline used for unanswered invites. Cancelled one-off events and series no longer disappear ([#388]).
- **One notification per event.** An event with several reminders now keeps a single notification, which each later reminder updates in place ([#463]).
- **Go further back and forward in time.** Every view and the date picker now reach from 1600 to 2399 ([#457]).
- **The split month agenda only lists days with events.** In other months it no longer starts with an empty 1st, and a month with nothing in it says so in one row ([#142]).
### Fixed
- **Widgets show your changes straight away.** After you added, edited or deleted an event, a widget could keep showing the old events until something else redrew it ([#420], [#229]).
- **Widgets follow the app's language.** After you picked a language in Settings, widgets kept the old one until something redrew them ([#90]).
- **The month widget handles busy months.** It could show "Can't show content", or only one or two events per day. Each day now shows as many events as its height fits, and the rest fold into +N ([#372]).
- **Short events late in the day are fully visible.** An event like 23:58–23:59 used to be cut down to a sliver at the bottom of the day, week and 3-day views. It now keeps its usual minimum height and sits flush against midnight ([#483]).
- **The month view no longer crashes after going before 1900** in the day or week view ([#457]).
- **Month pages keep updating.** After a small swipe that snapped back, a month page could stop showing new or edited events, and opening an event from the split view could show its old times ([#440], [#454]).
- **Smoother expand and collapse in the split month view.** The day cells morph cleanly between the two layouts, and week numbers now animate along with them ([#473]).
- **Back clears the selected day in the split month view.** With a day picked, the back gesture used to close the app; it now goes back to the whole month's agenda first ([#142]).
## [2.23.1] — 2026-10-28
### Changed
- **The split month view fits more events.** With a day selected, its date is no longer shown twice: the filter chip now stands in for the day's header. Tapping the chip opens the day, and its ✕ clears the filter. The day headers in the month agenda below the grid take less room too ([#142]).
### Fixed
- **Event dots in the split month grid stay in place on busy days.** A day with a "+N" next to its dots used to push them further down the cell ([#142]).
## [2.23.0] — 2026-10-21 ## [2.23.0] — 2026-10-21
### Added ### Added
@@ -1839,6 +1905,7 @@ automatically, with zero telemetry and no internet permission.
[#146]: https://codeberg.org/jlmakiola/calendula/issues/146 [#146]: https://codeberg.org/jlmakiola/calendula/issues/146
[#267]: https://codeberg.org/jlmakiola/calendula/issues/267 [#267]: https://codeberg.org/jlmakiola/calendula/issues/267
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239 [#239]: https://codeberg.org/jlmakiola/calendula/issues/239
[#296]: https://codeberg.org/jlmakiola/calendula/issues/296
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297 [#297]: https://codeberg.org/jlmakiola/calendula/issues/297
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298 [#298]: https://codeberg.org/jlmakiola/calendula/issues/298
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309 [#309]: https://codeberg.org/jlmakiola/calendula/issues/309
@@ -1888,4 +1955,33 @@ automatically, with zero telemetry and no internet permission.
[#306]: https://codeberg.org/jlmakiola/calendula/issues/306 [#306]: https://codeberg.org/jlmakiola/calendula/issues/306
[#178]: https://codeberg.org/jlmakiola/calendula/issues/178 [#178]: https://codeberg.org/jlmakiola/calendula/issues/178
[#116]: https://codeberg.org/jlmakiola/calendula/issues/116 [#116]: https://codeberg.org/jlmakiola/calendula/issues/116
[#388]: https://codeberg.org/jlmakiola/calendula/issues/388
[#413]: https://codeberg.org/jlmakiola/calendula/issues/413 [#413]: https://codeberg.org/jlmakiola/calendula/issues/413
[#71]: https://codeberg.org/jlmakiola/calendula/issues/71
[#90]: https://codeberg.org/jlmakiola/calendula/issues/90
[#229]: https://codeberg.org/jlmakiola/calendula/issues/229
[#292]: https://codeberg.org/jlmakiola/calendula/issues/292
[#372]: https://codeberg.org/jlmakiola/calendula/issues/372
[#376]: https://codeberg.org/jlmakiola/calendula/issues/376
[#386]: https://codeberg.org/jlmakiola/calendula/issues/386
[#420]: https://codeberg.org/jlmakiola/calendula/issues/420
[#435]: https://codeberg.org/jlmakiola/calendula/issues/435
[#436]: https://codeberg.org/jlmakiola/calendula/issues/436
[#437]: https://codeberg.org/jlmakiola/calendula/issues/437
[#438]: https://codeberg.org/jlmakiola/calendula/issues/438
[#439]: https://codeberg.org/jlmakiola/calendula/issues/439
[#440]: https://codeberg.org/jlmakiola/calendula/issues/440
[#441]: https://codeberg.org/jlmakiola/calendula/issues/441
[#454]: https://codeberg.org/jlmakiola/calendula/issues/454
[#457]: https://codeberg.org/jlmakiola/calendula/issues/457
[#463]: https://codeberg.org/jlmakiola/calendula/issues/463
[#473]: https://codeberg.org/jlmakiola/calendula/issues/473
[#476]: https://codeberg.org/jlmakiola/calendula/issues/476
[#483]: https://codeberg.org/jlmakiola/calendula/issues/483
[#448]: https://codeberg.org/jlmakiola/calendula/issues/448
[#475]: https://codeberg.org/jlmakiola/calendula/issues/475
[#489]: https://codeberg.org/jlmakiola/calendula/issues/489
[#491]: https://codeberg.org/jlmakiola/calendula/issues/491
[#493]: https://codeberg.org/jlmakiola/calendula/issues/493
[#494]: https://codeberg.org/jlmakiola/calendula/issues/494
[#501]: https://codeberg.org/jlmakiola/calendula/issues/501
+1 -1
View File
@@ -7,4 +7,4 @@ source "https://rubygems.org"
# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is # 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 # committed on purpose: this resolves an uploader's transitive deps, not the
# app's, and none of it affects the reproducible release build. # app's, and none of it affects the reproducible release build.
gem "fastlane", "2.237.0" gem "fastlane", "2.241.1"
+3 -2
View File
@@ -32,8 +32,8 @@ android {
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001, // (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match. // 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md. // See docs/RELEASING.md.
versionCode = 2230099 versionCode = 2240299
versionName = "2.23.0" versionName = "2.24.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -181,6 +181,7 @@ dependencies {
implementation("de.jeanlucmakiola.floret:core-android") implementation("de.jeanlucmakiola.floret:core-android")
implementation("de.jeanlucmakiola.floret:identity") implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components") implementation("de.jeanlucmakiola.floret:components")
implementation("de.jeanlucmakiola.floret:glance")
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
@@ -0,0 +1,140 @@
package de.jeanlucmakiola.calendula.widget.month
import android.Manifest
import android.content.ContentUris
import android.content.ContentValues
import android.net.Uri
import android.os.Parcel
import android.provider.CalendarContract
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.glance.ExperimentalGlanceApi
import androidx.glance.appwidget.compose
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import de.jeanlucmakiola.calendula.R
import java.time.LocalDate
import java.time.ZoneId
/**
* Renders the month widget for a packed month through Glance's own translator
* (#372) and checks what one size costs on the wire: the host's binder buffer is
* 1 MB for every widget update at once, and Exact sends one copy per size (#214).
*/
@OptIn(ExperimentalGlanceApi::class)
@RunWith(AndroidJUnit4::class)
class MonthWidgetPayloadTest {
@get:Rule
val permissionRule: GrantPermissionRule =
GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val context = instrumentation.targetContext
private var calendarId = -1L
private fun Uri.asSyncAdapter(): Uri = buildUpon()
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT)
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
.build()
@Before
fun packTheMonth() {
val calendar = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
put(CalendarContract.Calendars.NAME, ACCOUNT)
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, ACCOUNT)
put(CalendarContract.Calendars.CALENDAR_COLOR, 0xFF3F51B5.toInt())
put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_OWNER)
put(CalendarContract.Calendars.OWNER_ACCOUNT, ACCOUNT)
put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
}
val uri = context.contentResolver.insert(CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(), calendar)
calendarId = ContentUris.parseId(requireNotNull(uri))
val zone = ZoneId.systemDefault()
val first = LocalDate.now(zone).withDayOfMonth(1).minusDays(7)
val events = (0 until 49).flatMap { offset ->
val day = first.plusDays(offset.toLong())
(0 until EVENTS_PER_DAY).map { i ->
val start = day.atTime(8 + i, 0).atZone(zone).toInstant().toEpochMilli()
ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, calendarId)
put(CalendarContract.Events.TITLE, "Busy $i")
put(CalendarContract.Events.DTSTART, start)
put(CalendarContract.Events.DTEND, start + 30 * 60_000L)
put(CalendarContract.Events.EVENT_TIMEZONE, zone.id)
}
}
}
context.contentResolver.bulkInsert(CalendarContract.Events.CONTENT_URI.asSyncAdapter(), events.toTypedArray())
}
@After
fun removeCalendar() {
if (calendarId < 0) return
context.contentResolver.delete(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId).asSyncAdapter(),
null,
null,
)
}
@Test
fun aPackedMonthRendersWithinItsPayload() = runBlocking {
for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp), DpSize(600.dp, 1000.dp))) {
val views = MonthWidget().compose(context, size = size)
val bytes = Parcel.obtain().let { parcel ->
try {
views.writeToParcel(parcel, 0)
parcel.dataSize()
} finally {
parcel.recycle()
}
}
var dayNumbers = 0
var bars = 0
instrumentation.runOnMainSync {
val root = views.apply(context, FrameLayout(context))
fun walk(view: View) {
if (view.id in DAY_IDS && view is TextView && view.text.isNotEmpty()) dayNumbers += 1
if (view.id == R.id.bar_title) bars += 1
if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i))
}
walk(root)
}
Log.i(TAG, "size=$size bytes=$bytes days=$dayNumbers bars=$bars")
// The grid itself rendered, not Glance's error layout: a number in every day of every week.
assertThat(dayNumbers % 7).isEqualTo(0)
assertThat(dayNumbers).isAtLeast(28)
assertThat(bytes).isAtMost(MAX_BYTES_PER_SIZE)
}
}
private companion object {
const val TAG = "MonthWidgetPayload"
const val ACCOUNT = "calendula-widget-payload-test"
const val EVENTS_PER_DAY = 6
val DAY_IDS = setOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6)
/** A quarter of the host's buffer, leaving room for a second size and every other widget. */
const val MAX_BYTES_PER_SIZE = 256 * 1024
}
}
+118 -6
View File
@@ -41,6 +41,11 @@
--> -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- WorkManager declares these for network constraints and foreground workers, neither of which the app uses (#448). -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
<uses-permission android:name="android.permission.WAKE_LOCK" tools:node="remove" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" tools:node="remove" />
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage <!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
returns null and the calendar manager's per-account "manage" button can't returns null and the calendar manager's per-account "manage" button can't
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
@@ -318,6 +323,16 @@
android:exported="false" /> android:exported="false" />
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. --> <!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
<activity
android:name=".widget.config.WidgetConfigActivity"
android:exported="true"
android:excludeFromRecents="true"
android:taskAffinity=""
android:theme="@style/Theme.Calendula.WidgetConfig">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<receiver <receiver
android:name=".widget.agenda.AgendaWidgetReceiver" android:name=".widget.agenda.AgendaWidgetReceiver"
android:label="@string/widget_agenda_label" android:label="@string/widget_agenda_label"
@@ -342,14 +357,110 @@
android:resource="@xml/appwidget_info_month" /> android:resource="@xml/appwidget_info_month" />
</receiver> </receiver>
<receiver
android:name=".widget.quickadd.QuickAddWidgetReceiver"
android:label="@string/widget_quick_add_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_quick_add" />
</receiver>
<receiver
android:name=".widget.date.DateWidgetReceiver"
android:label="@string/widget_date_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_date" />
</receiver>
<receiver
android:name=".widget.week.WeekStripWidgetReceiver"
android:label="@string/widget_week_strip_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_week_strip" />
</receiver>
<receiver
android:name=".widget.upcoming.UpcomingWidgetReceiver"
android:label="@string/widget_upcoming_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_upcoming" />
</receiver>
<receiver
android:name=".widget.today.TodayWidgetReceiver"
android:label="@string/widget_today_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_today" />
</receiver>
<receiver
android:name=".widget.countdown.CountdownWidgetReceiver"
android:label="@string/widget_countdown_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_countdown" />
</receiver>
<!-- A countdown widget's redraw for when its label changes (#439). -->
<receiver
android:name=".widget.WidgetTickReceiver"
android:exported="false" />
<receiver
android:name=".widget.split.MonthDayWidgetReceiver"
android:label="@string/widget_month_day_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_month_day" />
</receiver>
<!-- Keeps both widgets fresh: the calendar provider broadcasts <!-- Keeps both widgets fresh: the calendar provider broadcasts
PROVIDER_CHANGED on any data change (our writes and external sync). PROVIDER_CHANGED on any data change (external sync; the app's own
The day boundary arrives as the app's own ROLLOVER alarm (#228), by writes redraw directly). The day boundary arrives as the app's own
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is ROLLOVER alarm (#228), by explicit PendingIntent, so it needs no
a free extra only, since Android 8+ withholds it from manifest filter here; DATE_CHANGED is a free extra only, since Android 8+
receivers. The four below re-arm that alarm: TIME_SET / withholds it from manifest receivers. TIME_SET / TIMEZONE_CHANGED
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it. move the boundary, boot / package-replace wipe the alarm, and
LOCALE_CHANGED redraws the labels in the new language.
Exported: the system broadcasts arrive from outside the app. --> Exported: the system broadcasts arrive from outside the app. -->
<!-- The app's own redraw request (language change); not exported so
other apps can't trigger it. -->
<receiver
android:name=".widget.WidgetRedrawReceiver"
android:exported="false" />
<receiver <receiver
android:name=".widget.WidgetUpdateReceiver" android:name=".widget.WidgetUpdateReceiver"
android:exported="true"> android:exported="true">
@@ -365,6 +476,7 @@
<action android:name="android.intent.action.TIMEZONE_CHANGED" /> <action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.BOOT_COMPLETED" /> <action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" /> <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.LOCALE_CHANGED" />
</intent-filter> </intent-filter>
</receiver> </receiver>
@@ -10,9 +10,12 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler import de.jeanlucmakiola.calendula.widget.publishCalendulaWidgetPreviews
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.locale.AppLanguage
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -43,6 +46,11 @@ class CalendulaApp : Application() {
chooseIssueUrl = getString(R.string.report_issue_choose_url), chooseIssueUrl = getString(R.string.report_issue_choose_url),
), ),
) )
// Below API 33 only AppCompat activities get the app language; a process
// woken for a widget formats in it too (#90).
AppLanguage.applyDefault(this)
// Before anything that writes, so the widgets see every change (#420).
widgetEntryPoint().widgetRefreshCoordinator().start()
reconcileAutoBackup() reconcileAutoBackup()
reconcileSpecialDates() reconcileSpecialDates()
reconcileCalendarVisibility() reconcileCalendarVisibility()
@@ -55,10 +63,12 @@ class CalendulaApp : Application() {
* (#228). Idempotent, and it covers what no broadcast reaches — an alarm * (#228). Idempotent, and it covers what no broadcast reaches — an alarm
* dropped by a force-stop is armed again the next time the app is opened. * dropped by a force-stop is armed again the next time the app is opened.
* Off the main thread: a handful of binder calls on every process start. * Off the main thread: a handful of binder calls on every process start.
* The picker previews are republished here too, once per update.
*/ */
private fun reconcileWidgetRollover() { private fun reconcileWidgetRollover() {
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
WidgetRolloverScheduler.sync(this@CalendulaApp) syncWidgetRollover(this@CalendulaApp)
publishCalendulaWidgetPreviews(this@CalendulaApp)
} }
} }
@@ -30,6 +30,7 @@ import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.locale.AppLanguage
import de.jeanlucmakiola.floret.prefs.ThemeMode import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.isDark import de.jeanlucmakiola.floret.prefs.isDark
import de.jeanlucmakiola.floret.prefs.isPureBlack import de.jeanlucmakiola.floret.prefs.isPureBlack
@@ -278,6 +279,7 @@ class MainActivity : AppCompatActivity() {
super.onResume() super.onResume()
// Reaching a running UI means startup succeeded; reset the loop trail. // Reaching a running UI means startup succeeded; reset the loop trail.
CrashReporter.markHealthy(this) CrashReporter.markHealthy(this)
AppLanguage.syncMirror(this)
} }
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
@@ -593,7 +595,9 @@ class MainActivity : AppCompatActivity() {
*/ */
fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent = fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent =
Intent(context, MainActivity::class.java).apply { Intent(context, MainActivity::class.java).apply {
data = "calendula://date/$date".toUri() // The source is part of the URI so widgets of different kinds
// tapping the same date don't share one PendingIntent.
data = "calendula://date/$date/${source.name}".toUri()
putExtra(EXTRA_DATE_ISO, date.toString()) putExtra(EXTRA_DATE_ISO, date.toString())
putExtra(EXTRA_SOURCE_VIEW, source.name) putExtra(EXTRA_SOURCE_VIEW, source.name)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -612,6 +616,7 @@ class MainActivity : AppCompatActivity() {
endMillis: Long, endMillis: Long,
source: CalendarView, source: CalendarView,
): Intent = eventDetailIntent(context, eventId, beginMillis, endMillis).apply { ): Intent = eventDetailIntent(context, eventId, beginMillis, endMillis).apply {
data = "calendula://event/$eventId/$beginMillis/${source.name}".toUri()
putExtra(EXTRA_SOURCE_VIEW, source.name) putExtra(EXTRA_SOURCE_VIEW, source.name)
} }
@@ -56,6 +56,10 @@ import javax.inject.Singleton
interface CalendarDataSource { interface CalendarDataSource {
fun calendars(): List<CalendarSource> fun calendars(): List<CalendarSource>
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
/** [instances] of event [eventId] alone, soonest first. */
fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List<EventInstance> =
instances(beginMillis, endMillis).filter { it.eventId == eventId }
/** /**
* [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to * [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to
* decode their stored offsets back to whole-day lead times (see * decode their stored offsets back to whole-day lead times (see
@@ -564,20 +568,60 @@ class AndroidCalendarDataSource @Inject constructor(
} }
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> { override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
val listed = queryInstances(beginMillis, endMillis, null, null)
// A provider that does expand cancelled events already listed them.
val shown = listed.mapTo(HashSet()) { it.eventId }
val cancelled = queryCancelledOneOffs(beginMillis, endMillis).filter { it.eventId !in shown }
return if (cancelled.isEmpty()) listed else (listed + cancelled).sortedBy { it.start }
}
/** Cancelled one-off events overlapping the window, which AOSP leaves out of Instances (#388). */
private fun queryCancelledOneOffs(beginMillis: Long, endMillis: Long): List<EventInstance> {
val selection = "${CalendarContract.Events.STATUS} = ${CalendarContract.Events.STATUS_CANCELED} AND " +
"${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.RRULE} IS NULL AND ${CalendarContract.Events.RDATE} IS NULL AND " +
"${CalendarContract.Events.ORIGINAL_ID} IS NULL AND " +
"${CalendarContract.Events.ORIGINAL_SYNC_ID} IS NULL AND " +
"${CalendarContract.Events.DTSTART} < ? AND " +
"COALESCE(${CalendarContract.Events.DTEND}, ${CalendarContract.Events.DTSTART}) >= ?"
return resolver.query(
CalendarContract.Events.CONTENT_URI,
CancelledEventProjection.COLUMNS,
selection,
arrayOf(endMillis.toString(), beginMillis.toString()),
null,
)?.use { c -> c.mapRowsNotNull { toCancelledOneOff() } } ?: emptyList()
}
override fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List<EventInstance> =
// The series' own rows plus its exceptions, so an occurrence edited on
// its own is still one of the series' occurrences.
queryInstances(
beginMillis,
endMillis,
"(${CalendarContract.Instances.EVENT_ID} = ? OR ${CalendarContract.Instances.ORIGINAL_ID} = ?)",
arrayOf(eventId.toString(), eventId.toString()),
)
private fun queryInstances(
beginMillis: Long,
endMillis: Long,
extraSelection: String?,
args: Array<String>?,
): List<EventInstance> {
ensureObserversRegistered() ensureObserversRegistered()
// Hide deleted occurrences (cancelled exceptions, #47) but keep a cancelled
// event of its own (#388); NULL status is a normal event. DELETED: #364.
val visible = "(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED} OR " +
"(${CalendarContract.Instances.ORIGINAL_ID} IS NULL AND " +
"${CalendarContract.Instances.ORIGINAL_SYNC_ID} IS NULL)) AND " +
"${CalendarContract.Events.DELETED} = 0"
return resolver.query( return resolver.query(
instancesUri(beginMillis, endMillis), instancesUri(beginMillis, endMillis),
InstanceProjection.COLUMNS, InstanceProjection.COLUMNS,
// Hide cancelled occurrences: "delete only this event" writes a if (extraSelection == null) visible else "$visible AND $extraSelection",
// cancelled exception for the one instance (#47). A NULL status is a args,
// normal, un-cancelled event, so it must survive the filter — a bare
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
// DELETED = 0: a provider that soft-deletes may keep the row's
// instances until a purge (#364).
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
"${CalendarContract.Events.DELETED} = 0",
null,
CalendarContract.Instances.BEGIN + " ASC", CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList() )?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList()
} }
@@ -601,7 +645,8 @@ class AndroidCalendarDataSource @Inject constructor(
.toTypedArray() .toTypedArray()
val selection = "($match) AND " + val selection = "($match) AND " +
"${CalendarContract.Events.DELETED} = 0 AND " + "${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.ORIGINAL_ID} IS NULL" "${CalendarContract.Events.ORIGINAL_ID} IS NULL AND " +
"${CalendarContract.Events.ORIGINAL_SYNC_ID} IS NULL"
return resolver.query( return resolver.query(
CalendarContract.Events.CONTENT_URI, CalendarContract.Events.CONTENT_URI,
SearchProjection.COLUMNS, SearchProjection.COLUMNS,
@@ -10,14 +10,22 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlin.time.Instant import kotlin.time.Instant
interface CalendarRepository { interface CalendarRepository {
fun calendars(): Flow<List<CalendarSource>> fun calendars(): Flow<List<CalendarSource>>
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
/** One read of event [eventId]'s occurrences overlapping [range], soonest first, whatever calendar it is in. */
suspend fun eventOccurrences(eventId: Long, range: ClosedRange<Instant>): List<EventInstance>
/** Re-query every open [calendars] / [instances] flow. */ /** Re-query every open [calendars] / [instances] flow. */
fun refresh() fun refresh()
/** Emits on the app's own writes and visibility changes; not on provider notifications or [refresh]. */
val localChanges: Flow<Unit>
suspend fun eventDetail(eventId: Long): EventDetail suspend fun eventDetail(eventId: Long): EventDetail
/** /**
@@ -101,6 +109,12 @@ interface CalendarRepository {
form: EventForm, form: EventForm,
): Long ): Long
/**
* Every series split the app makes ([updateEventFromOccurrence]), so what
* follows a series by id can move on to the part that continues it.
*/
val seriesSplits: Flow<SeriesSplit> get() = emptyFlow()
/** /**
* Change a recurring event from [beginMillis] onwards (series split); * Change a recurring event from [beginMillis] onwards (series split);
* returns the new event's `Events._ID`. * returns the new event's `Events._ID`.
@@ -128,3 +142,6 @@ class NoSuchEventException(eventId: Long) :
/** A ContentResolver write affected no rows or returned no URI. */ /** A ContentResolver write affected no rows or returned no URI. */
class WriteFailedException(operation: String) : class WriteFailedException(operation: String) :
RuntimeException("Calendar write failed: $operation") RuntimeException("Calendar write failed: $operation")
/** A "this and following" edit: [oldEventId] now ends where [newEventId] picks up. */
data class SeriesSplit(val oldEventId: Long, val newEventId: Long)
@@ -78,15 +78,28 @@ class CalendarRepositoryImpl @Inject constructor(
ticks.tryEmit(Unit) ticks.tryEmit(Unit)
} }
private val writes = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Run a provider write on [io], then re-query whether or not it threw. */ /** Run a provider write on [io], then re-query whether or not it threw. */
private suspend fun <T> write(block: suspend () -> T): T = withContext(io) { private suspend fun <T> write(block: suspend () -> T): T = withContext(io) {
try { try {
block() block()
} finally { } finally {
refresh() refresh()
writes.tryEmit(Unit)
} }
} }
override val localChanges: Flow<Unit> = merge(
writes,
prefs.pendingDisabledCalendarIds.distinctUntilChanged().drop(1).map {},
prefs.hiddenCalendarIds.distinctUntilChanged().drop(1).map {},
)
/** /**
* Re-query signal for everything filtered by visibility: the provider's own * Re-query signal for everything filtered by visibility: the provider's own
* notifications, plus every change to the pending switch-off set. * notifications, plus every change to the pending switch-off set.
@@ -181,6 +194,11 @@ class CalendarRepositoryImpl @Inject constructor(
cachedCalendars cachedCalendars
} }
override suspend fun eventOccurrences(eventId: Long, range: ClosedRange<Instant>): List<EventInstance> =
withContext(io) {
dataSource.eventInstances(eventId, range.start.toEpochMillis(), range.endInclusive.toEpochMillis())
}
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) { override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
dataSource.eventDetail(eventId, allDayReminderTimeMinutes()) dataSource.eventDetail(eventId, allDayReminderTimeMinutes())
?: throw NoSuchEventException(eventId) ?: throw NoSuchEventException(eventId)
@@ -333,7 +351,11 @@ class CalendarRepositoryImpl @Inject constructor(
dataSource.updateEventFromOccurrence( dataSource.updateEventFromOccurrence(
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(), eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
) )
} }.also { newId -> if (newId != eventId) splits.tryEmit(SeriesSplit(eventId, newId)) }
private val splits = MutableSharedFlow<SeriesSplit>(extraBufferCapacity = 8)
override val seriesSplits: Flow<SeriesSplit> = splits
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write { override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write {
dataSource.deleteOccurrence(eventId, beginMillis) dataSource.deleteOccurrence(eventId, beginMillis)
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.forNewEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.nearestTo import de.jeanlucmakiola.calendula.domain.nearestTo
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
@@ -132,6 +133,8 @@ internal fun buildEventInsertValues(
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone) put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue()) put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue()) put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
form.status.forNewEvent().takeIf { it != EventStatus.Confirmed }
?.let { put(CalendarContract.Events.STATUS, it.toProviderStatus()) }
form.location.trim().takeIf { it.isNotEmpty() } form.location.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) } ?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
form.description.trim().takeIf { it.isNotEmpty() } form.description.trim().takeIf { it.isNotEmpty() }
@@ -179,6 +182,9 @@ internal fun buildEventUpdateValues(
if (updated.accessLevel != original.accessLevel) { if (updated.accessLevel != original.accessLevel) {
put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue()) put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue())
} }
if (updated.status != original.status) {
put(CalendarContract.Events.STATUS, updated.status.toProviderStatus())
}
if (updated.colorKey != original.colorKey || updated.color != original.color) { if (updated.colorKey != original.colorKey || updated.color != original.color) {
putAll(eventColorColumns(updated.colorKey, updated.color)) putAll(eventColorColumns(updated.colorKey, updated.color))
} }
@@ -279,6 +285,7 @@ internal fun buildOccurrenceExceptionValues(
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone) put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue()) put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue()) put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
put(CalendarContract.Events.STATUS, form.status.forNewEvent().toProviderStatus())
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null }) put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null }) put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
if (form.colorKey == null) putAll(eventColorColumns(null, form.color)) if (form.colorKey == null) putAll(eventColorColumns(null, form.color))
@@ -354,7 +361,8 @@ internal fun buildMovedMasterValues(
} }
put(CalendarContract.Events.AVAILABILITY, snapshot.availability) put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel) put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) } snapshot.status?.takeIf { it != CalendarContract.Events.STATUS_CANCELED }
?.let { put(CalendarContract.Events.STATUS, it) }
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null }) put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null }) put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
} }
@@ -858,7 +866,7 @@ internal fun buildImportedEventValues(
// timed rows keep the event's own zone. // timed rows keep the event's own zone.
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId) put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue()) put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
put(CalendarContract.Events.STATUS, event.status.toProviderStatus()) put(CalendarContract.Events.STATUS, event.status.forNewEvent().toProviderStatus())
event.location?.trim()?.takeIf { it.isNotEmpty() } event.location?.trim()?.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) } ?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
event.description?.trim()?.takeIf { it.isNotEmpty() } event.description?.trim()?.takeIf { it.isNotEmpty() }
@@ -38,9 +38,17 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
color = color, color = color,
location = getString(InstanceProjection.IDX_LOCATION), location = getString(InstanceProjection.IDX_LOCATION),
response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)), response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)),
status = eventStatus(InstanceProjection.IDX_STATUS),
) )
} }
/**
* A [CancelledEventProjection] row as an instance. Its id is the negated event
* id, so it can't collide with a provider instance id.
*/
internal fun ColumnReader.toCancelledOneOff(): EventInstance? =
toEventInstance()?.let { it.copy(instanceId = -it.eventId) }
/** /**
* `SELF_ATTENDEE_STATUS` as the calendar surfaces read it: a tentative "maybe" * `SELF_ATTENDEE_STATUS` as the calendar surfaces read it: a tentative "maybe"
* counts as going, and everything that is not an open or refused invitation — * counts as going, and everything that is not an open or refused invitation —
@@ -62,6 +62,7 @@ internal object InstanceProjection {
CalendarContract.Instances.CALENDAR_COLOR, CalendarContract.Instances.CALENDAR_COLOR,
CalendarContract.Instances.EVENT_LOCATION, CalendarContract.Instances.EVENT_LOCATION,
CalendarContract.Instances.SELF_ATTENDEE_STATUS, CalendarContract.Instances.SELF_ATTENDEE_STATUS,
CalendarContract.Instances.STATUS,
) )
const val IDX_INSTANCE_ID = 0 const val IDX_INSTANCE_ID = 0
@@ -75,6 +76,29 @@ internal object InstanceProjection {
const val IDX_CALENDAR_COLOR = 8 const val IDX_CALENDAR_COLOR = 8
const val IDX_LOCATION = 9 const val IDX_LOCATION = 9
const val IDX_SELF_ATTENDEE_STATUS = 10 const val IDX_SELF_ATTENDEE_STATUS = 10
const val IDX_STATUS = 11
}
/**
* A cancelled one-off straight from the Events table, laid out on
* [InstanceProjection]'s indices so [toCancelledOneOff] reuses its mapper. AOSP
* writes no Instances rows for a cancelled event (#388).
*/
internal object CancelledEventProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events._ID,
CalendarContract.Events.CALENDAR_ID,
CalendarContract.Events.TITLE,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.EVENT_COLOR,
CalendarContract.Events.CALENDAR_COLOR,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.SELF_ATTENDEE_STATUS,
CalendarContract.Events.STATUS,
)
} }
internal object EventDetailProjection { internal object EventDetailProjection {
@@ -198,6 +222,7 @@ internal object SearchProjection {
// Excerpted, not just filtered on: a hit has to show what it matched. // Excerpted, not just filtered on: a hit has to show what it matched.
CalendarContract.Events.DESCRIPTION, CalendarContract.Events.DESCRIPTION,
CalendarContract.Events.SELF_ATTENDEE_STATUS, CalendarContract.Events.SELF_ATTENDEE_STATUS,
CalendarContract.Events.STATUS,
) )
const val IDX_ID = 0 const val IDX_ID = 0
@@ -214,6 +239,7 @@ internal object SearchProjection {
const val IDX_RDATE = 11 const val IDX_RDATE = 11
const val IDX_DESCRIPTION = 12 const val IDX_DESCRIPTION = 12
const val IDX_SELF_ATTENDEE_STATUS = 13 const val IDX_SELF_ATTENDEE_STATUS = 13
const val IDX_STATUS = 14
} }
/** /**
@@ -35,5 +35,6 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() || isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(), !getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)), response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)),
status = eventStatus(SearchProjection.IDX_STATUS),
) )
} }
@@ -146,6 +146,7 @@ class SettingsPrefs @Inject constructor(
val pastEventDisplay: Flow<PastEventDisplay> = prefs.flow(SettingsKeys.pastEventDisplay) val pastEventDisplay: Flow<PastEventDisplay> = prefs.flow(SettingsKeys.pastEventDisplay)
val dimCompletedEvents: Flow<Boolean> = prefs.flow(SettingsKeys.dimCompletedEvents) val dimCompletedEvents: Flow<Boolean> = prefs.flow(SettingsKeys.dimCompletedEvents)
val showWeekNumbers: Flow<Boolean> = prefs.flow(SettingsKeys.showWeekNumbers) val showWeekNumbers: Flow<Boolean> = prefs.flow(SettingsKeys.showWeekNumbers)
val monthShowStartTimes: Flow<Boolean> = prefs.flow(SettingsKeys.monthShowStartTimes)
val monthViewStyle: Flow<MonthViewStyle> = prefs.flow(SettingsKeys.monthViewStyle) val monthViewStyle: Flow<MonthViewStyle> = prefs.flow(SettingsKeys.monthViewStyle)
val timelineScale: Flow<TimelineScale> = prefs.flow(SettingsKeys.timelineScale) val timelineScale: Flow<TimelineScale> = prefs.flow(SettingsKeys.timelineScale)
val multiDayCount: Flow<Int> = prefs.flow(SettingsKeys.multiDayCount) val multiDayCount: Flow<Int> = prefs.flow(SettingsKeys.multiDayCount)
@@ -154,8 +155,6 @@ class SettingsPrefs @Inject constructor(
val dragToReschedule: Flow<Boolean> = prefs.flow(SettingsKeys.dragToReschedule) val dragToReschedule: Flow<Boolean> = prefs.flow(SettingsKeys.dragToReschedule)
val tapOpensEditor: Flow<Boolean> = prefs.flow(SettingsKeys.tapOpensEditor) val tapOpensEditor: Flow<Boolean> = prefs.flow(SettingsKeys.tapOpensEditor)
val agendaScreenRange: Flow<AgendaRange> = prefs.flow(SettingsKeys.agendaScreenRange) val agendaScreenRange: Flow<AgendaRange> = prefs.flow(SettingsKeys.agendaScreenRange)
val agendaWidgetRange: Flow<AgendaRange> = prefs.flow(SettingsKeys.agendaWidgetRange)
val widgetSize: Flow<WidgetSize> = prefs.flow(SettingsKeys.widgetSize)
val agendaShowRangeBar: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowRangeBar) val agendaShowRangeBar: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowRangeBar)
val agendaShowToday: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowToday) val agendaShowToday: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowToday)
val defaultView: Flow<CalendarView> = prefs.flow(SettingsKeys.defaultView) val defaultView: Flow<CalendarView> = prefs.flow(SettingsKeys.defaultView)
@@ -214,6 +213,7 @@ class SettingsPrefs @Inject constructor(
suspend fun setPastEventDisplay(mode: PastEventDisplay) = prefs.set(SettingsKeys.pastEventDisplay, mode) suspend fun setPastEventDisplay(mode: PastEventDisplay) = prefs.set(SettingsKeys.pastEventDisplay, mode)
suspend fun setDimCompletedEvents(enabled: Boolean) = prefs.set(SettingsKeys.dimCompletedEvents, enabled) suspend fun setDimCompletedEvents(enabled: Boolean) = prefs.set(SettingsKeys.dimCompletedEvents, enabled)
suspend fun setShowWeekNumbers(enabled: Boolean) = prefs.set(SettingsKeys.showWeekNumbers, enabled) suspend fun setShowWeekNumbers(enabled: Boolean) = prefs.set(SettingsKeys.showWeekNumbers, enabled)
suspend fun setMonthShowStartTimes(enabled: Boolean) = prefs.set(SettingsKeys.monthShowStartTimes, enabled)
suspend fun setMonthViewStyle(style: MonthViewStyle) = prefs.set(SettingsKeys.monthViewStyle, style) suspend fun setMonthViewStyle(style: MonthViewStyle) = prefs.set(SettingsKeys.monthViewStyle, style)
suspend fun setTimelineScale(scale: TimelineScale) = prefs.set(SettingsKeys.timelineScale, scale) suspend fun setTimelineScale(scale: TimelineScale) = prefs.set(SettingsKeys.timelineScale, scale)
suspend fun setMultiDayCount(count: Int) = prefs.set(SettingsKeys.multiDayCount, count) suspend fun setMultiDayCount(count: Int) = prefs.set(SettingsKeys.multiDayCount, count)
@@ -231,8 +231,6 @@ class SettingsPrefs @Inject constructor(
suspend fun setDragToReschedule(enabled: Boolean) = prefs.set(SettingsKeys.dragToReschedule, enabled) suspend fun setDragToReschedule(enabled: Boolean) = prefs.set(SettingsKeys.dragToReschedule, enabled)
suspend fun setTapOpensEditor(enabled: Boolean) = prefs.set(SettingsKeys.tapOpensEditor, enabled) suspend fun setTapOpensEditor(enabled: Boolean) = prefs.set(SettingsKeys.tapOpensEditor, enabled)
suspend fun setAgendaScreenRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaScreenRange, range) suspend fun setAgendaScreenRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaScreenRange, range)
suspend fun setAgendaWidgetRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaWidgetRange, range)
suspend fun setWidgetSize(size: WidgetSize) = prefs.set(SettingsKeys.widgetSize, size)
suspend fun setAgendaShowRangeBar(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowRangeBar, enabled) suspend fun setAgendaShowRangeBar(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowRangeBar, enabled)
suspend fun setAgendaShowToday(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowToday, enabled) suspend fun setAgendaShowToday(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowToday, enabled)
suspend fun setDefaultView(view: CalendarView) = prefs.set(SettingsKeys.defaultView, view) suspend fun setDefaultView(view: CalendarView) = prefs.set(SettingsKeys.defaultView, view)
@@ -449,6 +447,9 @@ object SettingsKeys {
*/ */
val showWeekNumbers: Pref<Boolean> = booleanPref("show_week_numbers", false) val showWeekNumbers: Pref<Boolean> = booleanPref("show_week_numbers", false)
/** Whether Month chips may show the start time before the title when wide enough (#219, #476). */
val monthShowStartTimes: Pref<Boolean> = booleanPref("month_show_start_times", true)
/** How the Month view lays itself out (#38, #53). */ /** How the Month view lays itself out (#38, #53). */
val monthViewStyle: Pref<MonthViewStyle> = enumPref("month_view_style", MonthViewStyle.Paged) val monthViewStyle: Pref<MonthViewStyle> = enumPref("month_view_style", MonthViewStyle.Paged)
@@ -497,13 +498,16 @@ object SettingsKeys {
/** How far ahead the in-app Agenda screen shows events (v2.11). */ /** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: Pref<AgendaRange> = agendaRangePref("agenda_screen_range") val agendaScreenRange: Pref<AgendaRange> = agendaRangePref("agenda_screen_range")
/** How far ahead the agenda **widget** shows events (v2.11). */ /**
* How far ahead the agenda **widget** shows events (v2.11). Read-only since
* widgets keep their own options (#292): the default for a widget that has
* none yet.
*/
val agendaWidgetRange: Pref<AgendaRange> = agendaRangePref("agenda_widget_range") val agendaWidgetRange: Pref<AgendaRange> = agendaRangePref("agenda_widget_range")
/** /**
* The size step the agenda widget draws its text at (#51). [WidgetSize.SMALL] * The size step the agenda widget draws its text at (#51). [WidgetSize.SMALL]
* reproduces its original metrics. The month widget takes no size setting — * reproduces its original metrics. Read-only like [agendaWidgetRange] (#292).
* it divides the width it is given by seven (#103).
*/ */
val widgetSize: Pref<WidgetSize> = enumPref("widget_size", WidgetSize.SMALL) val widgetSize: Pref<WidgetSize> = enumPref("widget_size", WidgetSize.SMALL)
@@ -511,8 +515,8 @@ object SettingsKeys {
val agendaShowRangeBar: Pref<Boolean> = booleanPref("agenda_show_range_bar", true) val agendaShowRangeBar: Pref<Boolean> = booleanPref("agenda_show_range_bar", true)
/** /**
* Whether the agenda (screen and widget) always anchors today at the top with * Whether the agenda screen always anchors today at the top with a "nothing
* a "nothing left today" placeholder (issue #35). * left today" placeholder (issue #35), and the default for a widget (#292).
*/ */
val agendaShowToday: Pref<Boolean> = booleanPref("agenda_show_today", true) val agendaShowToday: Pref<Boolean> = booleanPref("agenda_show_today", true)
@@ -38,6 +38,8 @@ class ReminderActionReceiver : BroadcastReceiver() {
ACTION_DISMISS -> notifier.cancel(alert) ACTION_DISMISS -> notifier.cancel(alert)
ACTION_SNOOZE -> { ACTION_SNOOZE -> {
// Tapped on a stale view: the reminder that replaced it stays put.
if (notifier.isSuperseded(alert)) return
// Cancel now so the notification doesn't linger until the alarm; // Cancel now so the notification doesn't linger until the alarm;
// the snooze delay read is the only async work. // the snooze delay read is the only async work.
notifier.cancel(alert) notifier.cancel(alert)
@@ -50,6 +52,8 @@ class ReminderActionReceiver : BroadcastReceiver() {
ACTION_SHOW -> { ACTION_SHOW -> {
goAsync { goAsync {
// A later reminder posted while this one was snoozed has taken its place.
if (notifier.isSuperseded(alert)) return@goAsync
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) { if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
notifier.post(alert) notifier.post(alert)
} }
@@ -82,12 +86,14 @@ class ReminderActionReceiver : BroadcastReceiver() {
/** /**
* An explicit intent to this receiver carrying [alert] as extras. The * An explicit intent to this receiver carrying [alert] as extras. The
* data URI duplicates no information but is what keeps two reminders' * data URI duplicates no information but is what keeps two reminders'
* `PendingIntent`s apart — `filterEquals` never compares extras. * `PendingIntent`s apart — `filterEquals` never compares extras. The
* snooze alarm is the exception: one per occurrence, so a later snooze
* replaces an earlier one and a newer reminder can cancel it.
*/ */
fun intent(context: Context, action: String, alert: ReminderAlert): Intent = fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
Intent(context, ReminderActionReceiver::class.java).apply { Intent(context, ReminderActionReceiver::class.java).apply {
this.action = action this.action = action
data = "calendula://reminder/${alert.key}".toUri() data = "calendula://reminder/${identity(alert, action)}".toUri()
putExtra(EXTRA_ALERT_KEY, alert.key) putExtra(EXTRA_ALERT_KEY, alert.key)
putExtra(EXTRA_EVENT_ID, alert.eventId) putExtra(EXTRA_EVENT_ID, alert.eventId)
putExtra(EXTRA_CALENDAR_ID, alert.calendarId) putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
@@ -101,7 +107,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
/** /**
* A stable request code per (alert, action), so one notification's * A stable request code per (alert, action), so one notification's
* PendingIntents stay distinct. The shift keeps the action slot intact; * PendingIntents stay distinct. The shift keeps the action slot intact;
* the top bits it drops are separated by [intent]'s per-reminder URI. * the top bits it drops are separated by [intent]'s URI.
*/ */
fun requestCode(alert: ReminderAlert, action: String): Int { fun requestCode(alert: ReminderAlert, action: String): Int {
val actionOffset = when (action) { val actionOffset = when (action) {
@@ -111,9 +117,12 @@ class ReminderActionReceiver : BroadcastReceiver() {
ACTION_OPEN -> 4 ACTION_OPEN -> 4
else -> 0 else -> 0
} }
return (alert.key.toInt() shl 3) + actionOffset return (identity(alert, action).toInt() shl 3) + actionOffset
} }
private fun identity(alert: ReminderAlert, action: String): Long =
if (action == ACTION_SHOW) alert.occurrenceKey else alert.key
private fun alertFrom(intent: Intent): ReminderAlert? { private fun alertFrom(intent: Intent): ReminderAlert? {
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
return ReminderAlert( return ReminderAlert(
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.data.reminders package de.jeanlucmakiola.calendula.data.reminders
import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
import de.jeanlucmakiola.calendula.domain.reminders.occurrenceKey
/** /**
* One reminder as the notification layer needs it: what to show, and the stable * One reminder as the notification layer needs it: what to show, and the stable
@@ -20,6 +21,13 @@ data class ReminderAlert(
val isAllDay: Boolean, val isAllDay: Boolean,
) )
/**
* The occurrence [ReminderAlert] belongs to. Keys the notification and the
* snooze alarm, so a later reminder for the same occurrence replaces the earlier
* one instead of stacking beside it (#463).
*/
val ReminderAlert.occurrenceKey: Long get() = occurrenceKey(eventId, beginMillis)
fun PlannedReminder.toAlert(): ReminderAlert = ReminderAlert( fun PlannedReminder.toAlert(): ReminderAlert = ReminderAlert(
key = key, key = key,
eventId = instance.eventId, eventId = instance.eventId,
@@ -6,6 +6,7 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
@@ -28,13 +29,13 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Posts one notification per due reminder on a dedicated channel. Tapping opens * Posts one notification per occurrence on a dedicated channel. Tapping opens
* the event's detail screen. * the event's detail screen.
* *
* The tag is the reminder's stable key, so a scan that posts the same reminder * The tag is the [occurrenceKey], so each reminder for an occurrence replaces
* again — a catch-up pass overlapping the alarm that already fired — replaces * the one before it in place (#463). A different reminder alerts afresh; the
* its notification silently ([NotificationCompat.Builder.setOnlyAlertOnce]) * same one posted again — a catch-up pass overlapping the alarm that already
* instead of stacking a second one. * fired — replaces its notification silently.
*/ */
@Singleton @Singleton
class ReminderNotifier @Inject constructor( class ReminderNotifier @Inject constructor(
@@ -65,7 +66,7 @@ class ReminderNotifier @Inject constructor(
suspend fun post(alert: ReminderAlert) { suspend fun post(alert: ReminderAlert) {
if (isSilenced(alert.calendarId)) return if (isSilenced(alert.calendarId)) return
ensureChannel() ensureChannel()
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) } val title = displayTitle(alert)
val is24Hour = settingsPrefs.timeFormat.first() val is24Hour = settingsPrefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(context)) .is24Hour(android.text.format.DateFormat.is24HourFormat(context))
val zone = ZoneId.systemDefault() val zone = ZoneId.systemDefault()
@@ -96,7 +97,8 @@ class ReminderNotifier @Inject constructor(
.setCategory(NotificationCompat.CATEGORY_EVENT) .setCategory(NotificationCompat.CATEGORY_EVENT)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true) .setAutoCancel(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(shownKey(alert) == alert.key)
.addExtras(Bundle().apply { putLong(EXTRA_REMINDER_KEY, alert.key) })
.setContentIntent(detailIntent(alert)) .setContentIntent(detailIntent(alert))
.addAction( .addAction(
R.drawable.ic_notification_snooze, R.drawable.ic_notification_snooze,
@@ -109,20 +111,58 @@ class ReminderNotifier @Inject constructor(
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS), actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
) )
.build() .build()
val manager = NotificationManagerCompat.from(context)
cancelLegacy(alert)
try { try {
NotificationManagerCompat.from(context) manager.notify(alert.occurrenceKey.toString(), NOTIFICATION_ID, notification)
.notify(alert.key.toString(), NOTIFICATION_ID, notification)
} catch (e: SecurityException) { } catch (e: SecurityException) {
// POST_NOTIFICATIONS was revoked between canPost() and here. // POST_NOTIFICATIONS was revoked between canPost() and here.
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e) Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
} }
} }
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */ /**
* Remove [alert]'s notification (snooze re-shows it later; dismiss is final),
* unless a later reminder has replaced it — an action tapped on a stale view
* mustn't take the newer one with it.
*/
fun cancel(alert: ReminderAlert) { fun cancel(alert: ReminderAlert) {
NotificationManagerCompat.from(context).cancel(alert.key.toString(), NOTIFICATION_ID) val manager = NotificationManagerCompat.from(context)
if (shownKey(alert) == alert.key) manager.cancel(alert.occurrenceKey.toString(), NOTIFICATION_ID)
// Posted before reminders shared a notification, under the reminder's own key.
manager.cancel(alert.key.toString(), NOTIFICATION_ID)
} }
/** Whether a different reminder for [alert]'s occurrence is on screen now. */
fun isSuperseded(alert: ReminderAlert): Boolean = shownKey(alert).let { it != null && it != alert.key }
/** The key of the reminder showing for [alert]'s occurrence, if any. */
private fun shownKey(alert: ReminderAlert): Long? {
val tag = alert.occurrenceKey.toString()
return NotificationManagerCompat.from(context).activeNotifications
.firstOrNull { it.tag == tag && it.id == NOTIFICATION_ID }
?.notification?.extras
?.takeIf { it.containsKey(EXTRA_REMINDER_KEY) }
?.getLong(EXTRA_REMINDER_KEY)
}
/**
* Remove a reminder for the same occurrence posted before reminders shared a
* notification. It carries no reminder key; its time and title identify it.
*/
private fun cancelLegacy(alert: ReminderAlert) {
val manager = NotificationManagerCompat.from(context)
manager.activeNotifications
.filter { it.id == NOTIFICATION_ID && it.notification.channelId == CHANNEL_ID }
.filter { !it.notification.extras.containsKey(EXTRA_REMINDER_KEY) }
.filter { it.notification.`when` == alert.beginMillis }
.filter { it.notification.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)?.toString() == displayTitle(alert) }
.forEach { manager.cancel(it.tag, it.id) }
}
private fun displayTitle(alert: ReminderAlert): String =
alert.title.ifBlank { context.getString(R.string.event_untitled) }
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent = private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
PendingIntent.getBroadcast( PendingIntent.getBroadcast(
context, context,
@@ -157,8 +197,10 @@ class ReminderNotifier @Inject constructor(
const val TAG = "ReminderNotifier" const val TAG = "ReminderNotifier"
const val CHANNEL_ID = "reminders" const val CHANNEL_ID = "reminders"
// One id, distinct tags: the tag (alert id) already keys the // One id, distinct tags: the tag (occurrence) already keys the
// notification, so a fixed id keeps cancellation/replacement simple. // notification, so a fixed id keeps cancellation/replacement simple.
const val NOTIFICATION_ID = 1 const val NOTIFICATION_ID = 1
const val EXTRA_REMINDER_KEY = "de.jeanlucmakiola.calendula.reminder_key"
} }
} }
@@ -43,6 +43,7 @@ class ReminderScanner @Inject constructor(
private val source: ReminderInstanceSource, private val source: ReminderInstanceSource,
private val calendarDataSource: CalendarDataSource, private val calendarDataSource: CalendarDataSource,
private val notifier: ReminderNotifier, private val notifier: ReminderNotifier,
private val snoozes: ReminderSnoozeScheduler,
private val alarms: ReminderAlarmScheduler, private val alarms: ReminderAlarmScheduler,
private val state: ReminderStatePrefs, private val state: ReminderStatePrefs,
private val settingsPrefs: SettingsPrefs, private val settingsPrefs: SettingsPrefs,
@@ -104,7 +105,11 @@ class ReminderScanner @Inject constructor(
) )
if (notifier.canPost()) { if (notifier.canPost()) {
schedule.due.forEach { notifier.post(it.toAlert()) } schedule.due.forEach {
val alert = it.toAlert()
snoozes.cancel(alert)
notifier.post(alert)
}
} }
// Advance even when nothing could be posted, so muting notifications // Advance even when nothing could be posted, so muting notifications
// doesn't build a backlog. // doesn't build a backlog.
@@ -33,4 +33,16 @@ class ReminderSnoozeScheduler @Inject constructor(
) )
alarmManager.setWakeup(triggerAtMillis, pendingIntent) alarmManager.setWakeup(triggerAtMillis, pendingIntent)
} }
/** Drop a snooze still pending for [alert]'s occurrence, so it can't re-show over a newer reminder. */
fun cancel(alert: ReminderAlert) {
val pendingIntent = PendingIntent.getBroadcast(
context,
ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_SHOW),
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE,
) ?: return
context.getSystemService<AlarmManager>()?.cancel(pendingIntent)
pendingIntent.cancel()
}
} }
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.reminders package de.jeanlucmakiola.calendula.data.reminders
import de.jeanlucmakiola.calendula.domain.runsMidnightToMidnight
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import java.time.DayOfWeek import java.time.DayOfWeek
import java.time.Instant import java.time.Instant
@@ -11,6 +12,8 @@ import java.time.format.FormatStyle
import java.time.format.TextStyle import java.time.format.TextStyle
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import java.util.Locale import java.util.Locale
import kotlinx.datetime.toKotlinTimeZone
import kotlin.time.Instant as KInstant
/** /**
* The one line of time context in a reminder notification. Pure so it can be * The one line of time context in a reminder notification. Pure so it can be
@@ -32,7 +35,8 @@ import java.util.Locale
* - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end) * - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end)
* *
* All-day instances store UTC midnights with an exclusive end, so they are read * All-day instances store UTC midnights with an exclusive end, so they are read
* in UTC and the end day is the last *covered* day (issue #308). * in UTC and the end day is the last *covered* day (issue #308). A timed event
* from local midnight to local midnight reads like one too, in [zone] (#296).
*/ */
fun reminderTimeText( fun reminderTimeText(
beginMillis: Long, beginMillis: Long,
@@ -47,11 +51,17 @@ fun reminderTimeText(
tomorrowLabel: String, tomorrowLabel: String,
yesterdayLabel: String, yesterdayLabel: String,
): String { ): String {
if (isAllDay) { val wholeDays = !isAllDay && runsMidnightToMidnight(
KInstant.fromEpochMilliseconds(beginMillis),
KInstant.fromEpochMilliseconds(endMillis),
zone.toKotlinTimeZone(),
)
if (isAllDay || wholeDays) {
val dayZone = if (isAllDay) ZoneOffset.UTC else zone
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
// (atZone().toLocalDate() instead of LocalDate.ofInstant — API 34+) // (atZone().toLocalDate() instead of LocalDate.ofInstant — API 34+)
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate() val firstDay = Instant.ofEpochMilli(beginMillis).atZone(dayZone).toLocalDate()
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate() val lastDay = Instant.ofEpochMilli(endMillis).atZone(dayZone).toLocalDate()
.minusDays(1) .minusDays(1)
.coerceAtLeast(firstDay) .coerceAtLeast(firstDay)
val start = relativeDayPrefix( val start = relativeDayPrefix(
@@ -1,18 +1,35 @@
package de.jeanlucmakiola.calendula.domain package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlin.time.Instant
/** Lower values appear first in lists and get the upper visible lanes in grids. */ /** Lower values appear first in lists and get the upper visible lanes in grids. */
fun EventInstance.displayPriority(zone: TimeZone): Int = when { fun EventInstance.displayPriority(zone: TimeZone): Int = when {
isDeclined -> 3 isCrossedOut -> 3
spansMultipleDays(zone) -> 0 spansMultipleDays(zone) -> 0
isAllDay -> 1 coversWholeDays(zone) -> 1
else -> 2 else -> 2
} }
/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */ /**
fun eventDisplayComparator(zone: TimeZone): Comparator<EventInstance> = * Shared order for agenda and split day lists. Grid bars add their lane geometry.
compareBy<EventInstance> { it.displayPriority(zone) } * Each event's priority is worked out once, not on every comparison.
.thenBy { it.start } */
.thenBy { it.title } fun List<EventInstance>.sortedForDisplay(zone: TimeZone): List<EventInstance> =
.thenBy { it.instanceId } map { Triple(it, it.displayPriority(zone), it.displayStart(zone)) }
.sortedWith(
compareBy<Triple<EventInstance, Int, Instant>> { it.second }
.thenBy { it.third }
.thenBy { it.first.title }
.thenBy { it.first.instanceId },
)
.map { it.first }
/**
* Where an event starts for ordering: a whole-day event at local midnight of its
* first day, since an all-day one is stored at UTC midnight and would otherwise
* sort around timed events by the device's offset (#296).
*/
internal fun EventInstance.displayStart(zone: TimeZone): Instant =
if (coversWholeDays(zone)) spanFirstDay(zone).atStartOfDayIn(zone) else start
@@ -42,6 +42,8 @@ data class EventForm(
val reminders: List<Int> = emptyList(), val reminders: List<Int> = emptyList(),
val availability: Availability = Availability.Busy, val availability: Availability = Availability.Busy,
val accessLevel: AccessLevel = AccessLevel.Default, val accessLevel: AccessLevel = AccessLevel.Default,
/** Confirmed / Tentative / Cancelled (#388). */
val status: EventStatus = EventStatus.Confirmed,
/** /**
* Bare RRULE value (`Events.RRULE` convention, no "RRULE:" prefix); null * Bare RRULE value (`Events.RRULE` convention, no "RRULE:" prefix); null
* means a one-off event. May hold rules the simple picker can't express — * means a one-off event. May hold rules the simple picker can't express —
@@ -113,6 +115,7 @@ enum class EventFormField {
Reminders, Reminders,
Recurrence, Recurrence,
Availability, Availability,
Status,
Visibility, Visibility,
Color, Color,
Attendees, Attendees,
@@ -178,6 +181,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
reminders = reminders.map { it.minutes }.distinct().sorted(), reminders = reminders.map { it.minutes }.distinct().sorted(),
availability = availability, availability = availability,
accessLevel = accessLevel, accessLevel = accessLevel,
status = status,
rrule = rrule.normalizedRrule(), rrule = rrule.normalizedRrule(),
// The provider fills EVENT_COLOR from the key, so [color] is the // The provider fills EVENT_COLOR from the key, so [color] is the
// swatch either way; a null colour means the event inherits its // swatch either way; a null colour means the event inherits its
@@ -213,7 +217,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
* re-deriving with the same occurrence would mask an externally moved * re-deriving with the same occurrence would mask an externally moved
* event. Guests are covered (the form writes editable attendees), so an * event. Guests are covered (the form writes editable attendees), so an
* external attendee change now also trips the conflict check. Still not * external attendee change now also trips the conflict check. Still not
* covered: status, the user's own response, reminder methods, the * covered: the user's own response, reminder methods, the
* organizer/resource rows, and a recurring event's duration. * organizer/resource rows, and a recurring event's duration.
*/ */
data class EditSnapshot( data class EditSnapshot(
@@ -243,6 +247,22 @@ internal fun allDayFormTimes(start: LocalDate, endExclusive: LocalDate?): Pair<F
/** The bare RRULE value the form edits; blank means none. */ /** The bare RRULE value the form edits; blank means none. */
internal fun String?.normalizedRrule(): String? = this?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() } internal fun String?.normalizedRrule(): String? = this?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }
/**
* Whether this form may be saved apart from its series, as one modified
* occurrence or as a split-off "this & following" tail. A cancelled exception
* row is how Android records a deleted occurrence, and Google reads an inserted
* cancelled event as deleted, so either would vanish rather than show struck
* through (#388).
*/
fun EventForm.canSaveApartFromSeries(): Boolean = status != EventStatus.Cancelled
/**
* The status a newly inserted row may carry. Google reads an inserted cancelled
* event as deleted, so every new row (a copy, an import, a calendar move or an
* exception) starts confirmed instead (#388).
*/
fun EventStatus.forNewEvent(): EventStatus = if (this == EventStatus.Cancelled) EventStatus.Confirmed else this
/** /**
* Whether this form's times mean something else in [target] — a timed event * Whether this form's times mean something else in [target] — a timed event
* pinned to another zone. False when there's nothing to disambiguate: an * pinned to another zone. False when there's nothing to disambiguate: an
@@ -269,6 +289,7 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (reminders.isNotEmpty()) add(EventFormField.Reminders) if (reminders.isNotEmpty()) add(EventFormField.Reminders)
if (rrule != null) add(EventFormField.Recurrence) if (rrule != null) add(EventFormField.Recurrence)
if (availability != Availability.Busy) add(EventFormField.Availability) if (availability != Availability.Busy) add(EventFormField.Availability)
if (status != EventStatus.Confirmed) add(EventFormField.Status)
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility) if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
if (colorKey != null || color != null) add(EventFormField.Color) if (colorKey != null || color != null) add(EventFormField.Color)
if (attendees.isNotEmpty()) add(EventFormField.Attendees) if (attendees.isNotEmpty()) add(EventFormField.Attendees)
@@ -311,4 +332,5 @@ fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
).copy( ).copy(
rrule = null, rrule = null,
calendarId = instance.calendarId.takeIf { canModify }, calendarId = instance.calendarId.takeIf { canModify },
status = status.forNewEvent(),
) )
@@ -173,8 +173,11 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
return copy(start = movedStart, end = (startAt + span).toFormTime(zone)) return copy(start = movedStart, end = (startAt + span).toFormTime(zone))
} }
/** The form with new start and end, each in the form's own zone — a resize. All-day is untouched. */ /**
* The form with new start and end, each in the form's own zone — a resize. An
* all-day event becomes timed: one dragged out of the all-day strip.
*/
fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm { fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm {
if (isAllDay) return this val timed = copy(isAllDay = false)
return withTimes(newStart, newEnd, resolvedZone(deviceZone)) return timed.withTimes(newStart, newEnd, timed.resolvedZone(deviceZone))
} }
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant import kotlin.time.Instant
@@ -72,6 +73,8 @@ data class EventInstance(
* (#180, #230). * (#180, #230).
*/ */
val response: EventResponse = EventResponse.Going, val response: EventResponse = EventResponse.Going,
/** The event's own Confirmed / Tentative / Cancelled (`Events.STATUS`, #388). */
val status: EventStatus = EventStatus.Confirmed,
) )
/** /**
@@ -94,16 +97,33 @@ enum class EventResponse {
Declined, Declined,
} }
/** Shorthand for the declined case, which most surfaces test on its own (#180). */ /**
val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined * Struck through on every surface and sorted last: an invitation you declined
* (#180), or an event its organizer cancelled (#388). Neither plans reminders.
*/
val EventInstance.isCrossedOut: Boolean
get() = response == EventResponse.Declined || status == EventStatus.Cancelled
/**
* Drawn as an outline, still undecided: an invitation you have not answered
* (#230), or a tentative event (#388).
*/
val EventInstance.isOutlined: Boolean
get() = !isCrossedOut && (response == EventResponse.Invited || status == EventStatus.Tentative)
/** /**
* Whether this event has finished relative to [now] — its end is at or before * Whether this event has finished relative to [now] — its end is at or before
* the current instant. An in-progress event (already started but not yet ended) * the current instant. An in-progress event (already started but not yet ended)
* is *not* considered ended. All-day events end at the exclusive next-midnight, * is *not* considered ended. All-day events end at the exclusive next-midnight
* so they only count as ended once their day is fully over. * in [zone] rather than the UTC midnight they are stored on, so they only count
* as ended once their local day is fully over.
*/ */
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now fun EventInstance.hasEnded(now: Instant, zone: TimeZone = TimeZone.currentSystemDefault()): Boolean =
localEnd(zone) <= now
/** [EventInstance.end], with an all-day event's UTC midnight moved to local midnight in [zone]. */
fun EventInstance.localEnd(zone: TimeZone): Instant =
if (isAllDay) end.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone) else end
/** /**
* The zone this event's calendar dates live in: the device [zone] for timed * The zone this event's calendar dates live in: the device [zone] for timed
@@ -130,6 +150,20 @@ fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean = fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
spanFirstDay(zone) != spanLastDay(zone) spanFirstDay(zone) != spanLastDay(zone)
/**
* Whether this event fills whole days in [zone], so the grids and lists lay it
* out with the all-day events: an all-day event, or a timed one running from
* local midnight to local midnight (#296). The event itself stays timed.
*/
fun EventInstance.coversWholeDays(zone: TimeZone): Boolean = isAllDay || runsMidnightToMidnight(start, end, zone)
/** Whether a timed span from [start] to [end] runs from local midnight to local midnight in [zone] (#296). */
fun runsMidnightToMidnight(start: Instant, end: Instant, zone: TimeZone): Boolean =
end > start && start.isStartOfDay(zone) && end.isStartOfDay(zone)
private fun Instant.isStartOfDay(zone: TimeZone): Boolean =
this == toLocalDateTime(zone).date.atStartOfDayIn(zone)
data class EventDetail( data class EventDetail(
val instance: EventInstance, val instance: EventInstance,
val description: String?, val description: String?,
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.allDayFormTimes import de.jeanlucmakiola.calendula.domain.allDayFormTimes
import de.jeanlucmakiola.calendula.domain.forNewEvent
import de.jeanlucmakiola.calendula.domain.normalizedRrule import de.jeanlucmakiola.calendula.domain.normalizedRrule
import de.jeanlucmakiola.calendula.domain.toFormTime import de.jeanlucmakiola.calendula.domain.toFormTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
@@ -35,6 +36,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
description = description.orEmpty(), description = description.orEmpty(),
reminders = semanticReminderMinutes(), reminders = semanticReminderMinutes(),
availability = availability, availability = availability,
status = status.forNewEvent(),
rrule = recurrenceRule.normalizedRrule(), rrule = recurrenceRule.normalizedRrule(),
) )
} }
@@ -32,19 +32,26 @@ data class PlannedReminder(
val minutes: Int, val minutes: Int,
val alarmMillis: Long, val alarmMillis: Long,
) { ) {
/** The occurrence this reminder belongs to; it shares one notification with its siblings (#463). */
val occurrenceKey: Long = occurrenceKey(instance.eventId, instance.beginMillis)
/** /**
* Stable identity, keying the notification tag and the snooze/dismiss * Stable identity of this one reminder, keying the snooze/dismiss
* `PendingIntent`s. Survives reboot, re-scan and reinstall. * `PendingIntent`s. Survives reboot, re-scan and reinstall.
*/ */
val key: Long = key(instance.eventId, instance.beginMillis, minutes) val key: Long = mix64(occurrenceKey + minutes)
}
private companion object { /** Stable identity of one occurrence, shared by every reminder on it. */
fun key(eventId: Long, beginMillis: Long, minutes: Int): Long { fun occurrenceKey(eventId: Long, beginMillis: Long): Long =
var h = eventId * 1_000_003L mix64(mix64(eventId) + beginMillis)
h = (h xor beginMillis) * 31L
return h + minutes /** SplitMix64's finaliser: spreads close inputs across all 64 bits. */
} private fun mix64(value: Long): Long {
} var z = value + -0x61c8864680b583ebL
z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
return z xor (z ushr 31)
} }
/** What one scan concluded: post these now, and wake up again at [nextAlarmMillis]. */ /** What one scan concluded: post these now, and wake up again at [nextAlarmMillis]. */
@@ -146,7 +153,8 @@ private fun allDayAlarmMillis(
* *
* Due means the fire instant falls in `(lastFiredMillis, nowMillis]`, so a scan * 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 * running twice cannot post twice while a late one still catches up. Reminders
* whose event has ended are dropped ([isStillRelevant]). [nextAlarmMillis] is * whose event has ended are dropped ([isStillRelevant]), and of several due on
* one occurrence only the latest is kept — they'd replace each other anyway (#463). [nextAlarmMillis] is
* capped at [horizonMillis] so the lookahead window keeps rolling forward. * capped at [horizonMillis] so the lookahead window keeps rolling forward.
*/ */
fun scheduleReminders( fun scheduleReminders(
@@ -158,7 +166,8 @@ fun scheduleReminders(
val due = planned val due = planned
.filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis } .filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis }
.filter { it.instance.isStillRelevant(nowMillis) } .filter { it.instance.isStillRelevant(nowMillis) }
.distinctBy { it.key } .groupBy { it.occurrenceKey }
.values.map { siblings -> siblings.maxBy { it.alarmMillis } }
.sortedWith(compareBy({ it.instance.beginMillis }, { it.key })) .sortedWith(compareBy({ it.instance.beginMillis }, { it.key }))
val nextPending = planned val nextPending = planned
.filter { it.alarmMillis > nowMillis } .filter { it.alarmMillis > nowMillis }
@@ -485,9 +485,8 @@ fun CalendarHost(
movableCalendarIds = movableCalendarIds, movableCalendarIds = movableCalendarIds,
dragEnabled = dragToReschedule, dragEnabled = dragToReschedule,
move = { reschedule.move(it) }, move = { reschedule.move(it) },
inFlight = reschedule.inFlight, drops = reschedule.drops,
undoStarted = reschedule.undoStarted, undoStarted = reschedule.undoStarted,
abandoned = reschedule.abandoned,
edit = onEditEvent, edit = onEditEvent,
) )
} }
@@ -32,7 +32,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.declinedTitle import de.jeanlucmakiola.calendula.ui.common.declinedTitle
@@ -59,11 +59,16 @@ import java.util.Locale
// split style can list a day with exactly the same visual language instead of // split style can list a day with exactly the same visual language instead of
// growing a parallel set of event rows. // growing a parallel set of event rows.
/**
* A day's header; tapping it opens the Day view. [compact] trims the padding for
* the month view's split pane, where vertical space is short (#142).
*/
@Composable @Composable
internal fun AgendaDayHeader( internal fun AgendaDayHeader(
date: LocalDate, date: LocalDate,
today: LocalDate, today: LocalDate,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
compact: Boolean = false,
) { ) {
Surface( Surface(
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
@@ -79,7 +84,12 @@ internal fun AgendaDayHeader(
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp), modifier = Modifier.padding(
start = 28.dp,
end = 28.dp,
top = if (compact) 8.dp else 16.dp,
bottom = if (compact) 4.dp else 8.dp,
),
) )
} }
} }
@@ -138,7 +148,7 @@ internal fun AgendaEventRow(
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow( GroupedRow(
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
title = declinedTitle(title, event.isDeclined), title = declinedTitle(title, event.isCrossedOut),
summary = AnnotatedString(agendaTimeSummary(event, day, zone)), summary = AnnotatedString(agendaTimeSummary(event, day, zone)),
position = position, position = position,
minHeight = 64.dp, minHeight = 64.dp,
@@ -220,10 +230,11 @@ internal fun LazyListScope.agendaDays(
dimmed: (EventInstance, LocalDate) -> Boolean, dimmed: (EventInstance, LocalDate) -> Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
compact: Boolean = false,
) { ) {
days.forEach { day -> days.forEach { day ->
stickyHeader(key = "header-${day.date}") { stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay) AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay, compact = compact)
} }
if (day.events.isEmpty()) { if (day.events.isEmpty()) {
item(key = "placeholder-${day.date}") { item(key = "placeholder-${day.date}") {
@@ -251,6 +262,6 @@ internal fun LazyListScope.agendaDays(
) )
} }
} }
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) } item(key = "gap-${day.date}") { Spacer(Modifier.height(if (compact) 4.dp else 8.dp)) }
} }
} }
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.agenda package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.ui.common.FabClearance
import de.jeanlucmakiola.calendula.ui.common.PublishChrome import de.jeanlucmakiola.calendula.ui.common.PublishChrome
import de.jeanlucmakiola.calendula.ui.common.ScreenActions import de.jeanlucmakiola.calendula.ui.common.ScreenActions
import de.jeanlucmakiola.calendula.ui.common.ScreenBody import de.jeanlucmakiola.calendula.ui.common.ScreenBody
@@ -432,7 +433,7 @@ private fun AgendaList(
modifier = modifier.nestedScroll(loadOnScrollUp), modifier = modifier.nestedScroll(loadOnScrollUp),
state = listState, state = listState,
// Bottom inset clears the FAB stack so the last row stays tappable. // Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp), contentPadding = PaddingValues(top = 8.dp, bottom = FabClearance),
) { ) {
agendaDays( agendaDays(
days = days, days = days,
@@ -1,7 +1,8 @@
package de.jeanlucmakiola.calendula.ui.agenda package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.domain.sortedForDisplay
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.spanFirstDay import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.domain.spanLastDay import de.jeanlucmakiola.calendula.domain.spanLastDay
@@ -18,7 +19,7 @@ import kotlin.time.Instant
* surface only formats the instants into its own locale/24h string. * surface only formats the instants into its own locale/24h string.
*/ */
sealed interface AgendaTimeLabel { sealed interface AgendaTimeLabel {
/** An all-day event, or a whole in-between day of a multi-day span. */ /** An event filling whole days (#296), or a whole in-between day of a multi-day span. */
data object AllDay : AgendaTimeLabel data object AllDay : AgendaTimeLabel
/** The first day of a multi-day timed event: when it begins. */ /** The first day of a multi-day timed event: when it begins. */
data class Starts(val start: Instant) : AgendaTimeLabel data class Starts(val start: Instant) : AgendaTimeLabel
@@ -30,7 +31,7 @@ sealed interface AgendaTimeLabel {
/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */ /** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */
fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel { fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel {
if (event.isAllDay) return AgendaTimeLabel.AllDay if (event.coversWholeDays(zone)) return AgendaTimeLabel.AllDay
val firstDay = event.spanFirstDay(zone) val firstDay = event.spanFirstDay(zone)
val lastDay = event.spanLastDay(zone) val lastDay = event.spanLastDay(zone)
return when { return when {
@@ -87,7 +88,7 @@ fun groupAgendaDays(
return byDay.map { (date, dayEvents) -> return byDay.map { (date, dayEvents) ->
AgendaDay( AgendaDay(
date = date, date = date,
events = dayEvents.sortedWith(eventDisplayComparator(zone)), events = dayEvents.sortedForDisplay(zone),
) )
} }
} }
@@ -30,7 +30,9 @@ fun CalendarDatePickerDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val state = rememberDatePickerState( val state = rememberDatePickerState(
initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY, initialSelectedDateMillis = initial.coerceIn(CalendarSpan.FIRST_DAY, CalendarSpan.LAST_DAY)
.toEpochDays() * MILLIS_PER_DAY,
yearRange = CalendarSpan.years,
) )
DatePickerDialog( DatePickerDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -38,6 +38,9 @@ import de.jeanlucmakiola.calendula.R
/** The standard FAB's size, shared by the selection bar that unrolls from it (#141). */ /** The standard FAB's size, shared by the selection bar that unrolls from it (#141). */
val FabSize = 56.dp val FabSize = 56.dp
/** Bottom inset that lets a scrolling view's last row or hour scroll clear of the FAB. */
val FabClearance = 96.dp
/** What the FAB is while an event is selected (#141): the toggle for its action bar. */ /** What the FAB is while an event is selected (#141): the toggle for its action bar. */
@Stable @Stable
class FabSelection( class FabSelection(
@@ -0,0 +1,14 @@
package de.jeanlucmakiola.calendula.ui.common
import kotlinx.datetime.LocalDate
/**
* The dates every view can show and every date picker offers. One span for all
* of them, so no view can hand another a date it has no page for (#457).
*/
object CalendarSpan {
val FIRST_DAY: LocalDate = LocalDate(1600, 1, 1)
val LAST_DAY: LocalDate = LocalDate(2399, 12, 31)
val years: IntRange get() = FIRST_DAY.year..LAST_DAY.year
}
@@ -8,9 +8,9 @@ import java.util.Locale
* out in [locale]'s own order, with the year shown only when [date] falls outside * out in [locale]'s own order, with the year shown only when [date] falls outside
* [currentYear]. * [currentYear].
* *
* Pass [skeleton] *without* a year field — "LLLL" for a month, "EEEdMMM" for a * Pass [skeleton] *without* a year field, e.g. "LLLL" for a month. A skeleton
* day. A skeleton is a field list, so wanting the year is just asking for one * is a field list, so wanting the year is just asking for one more field; the
* more field; the locale still decides where it lands ("July 2026" vs "2026年7月"). * locale still decides where it lands ("July 2026" vs "2026年7月").
* *
* Dropping the year in the current year is the one bit of policy here: the title * Dropping the year in the current year is the one bit of policy here: the title
* sits directly above a grid that already says which year it is, and the year's * sits directly above a grid that already says which year it is, and the year's
@@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.TaskAlt
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.EventFormField
@@ -29,6 +30,7 @@ fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Reminders -> R.string.event_detail_reminders EventFormField.Reminders -> R.string.event_detail_reminders
EventFormField.Recurrence -> R.string.event_detail_recurrence EventFormField.Recurrence -> R.string.event_detail_recurrence
EventFormField.Availability -> R.string.event_edit_availability EventFormField.Availability -> R.string.event_edit_availability
EventFormField.Status -> R.string.event_edit_status
EventFormField.Visibility -> R.string.event_edit_visibility EventFormField.Visibility -> R.string.event_edit_visibility
EventFormField.Color -> R.string.event_edit_color EventFormField.Color -> R.string.event_edit_color
EventFormField.Attendees -> R.string.event_edit_attendees EventFormField.Attendees -> R.string.event_edit_attendees
@@ -41,6 +43,7 @@ fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
EventFormField.Reminders -> Icons.Default.Notifications EventFormField.Reminders -> Icons.Default.Notifications
EventFormField.Recurrence -> Icons.Default.Repeat EventFormField.Recurrence -> Icons.Default.Repeat
EventFormField.Availability -> Icons.Default.EventAvailable EventFormField.Availability -> Icons.Default.EventAvailable
EventFormField.Status -> Icons.Default.TaskAlt
EventFormField.Visibility -> Icons.Default.Lock EventFormField.Visibility -> Icons.Default.Lock
EventFormField.Color -> Icons.Default.Palette EventFormField.Color -> Icons.Default.Palette
EventFormField.Attendees -> Icons.Default.People EventFormField.Attendees -> Icons.Default.People
@@ -90,7 +90,11 @@ fun EventMoveHost(
MoveOutcome.Undone -> stringResource(R.string.event_move_undone) MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied) MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
MoveOutcome.Gone -> stringResource(R.string.event_move_gone) MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
MoveOutcome.Stale -> stringResource(R.string.event_move_stale)
MoveOutcome.Skipped -> stringResource(R.string.event_move_skipped)
MoveOutcome.QueueFull -> stringResource(R.string.event_move_queue_full)
MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end) MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end)
MoveOutcome.BlockedCancelledSeries -> stringResource(R.string.event_move_blocked_cancelled_series)
MoveOutcome.Failed -> stringResource(R.string.event_move_failed) MoveOutcome.Failed -> stringResource(R.string.event_move_failed)
} }
@@ -1,12 +1,15 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import kotlinx.coroutines.delay
import androidx.compose.animation.core.tween
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -16,6 +19,7 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import java.util.concurrent.atomic.AtomicLong
/** /**
* Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by * Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by
@@ -37,24 +41,18 @@ class EventMoveScope(
val dragEnabled: Boolean, val dragEnabled: Boolean,
/** /**
* Hand a drop to the writer. Returns nothing on purpose: a drop is answered * Hand a drop to the writer. Returns nothing on purpose: a drop is answered
* before its write resolves, so [abandoned] — not a return value — is what * before its write resolves, so [drops] — not a return value — is what tells
* tells a held copy that nothing landed. * a held copy whether anything landed.
*/ */
val move: (MoveRequest) -> Unit, val move: (MoveRequest) -> Unit,
/** /**
* True while a dropped event is being written, including the time its scope * Each drop's progress by its token — see `RescheduleViewModel.drops`. A flow
* dialog is up. A flow rather than a value so this scope stays the same * rather than a value so this scope stays the same object across a move —
* object across a move — every visible block reads it as a composition local. * every visible block reads it as a composition local.
*/ */
val inFlight: StateFlow<Boolean>, val drops: StateFlow<DropStates>,
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */ /** The undo write that just began — see `RescheduleViewModel.undoStarted`. */
val undoStarted: StateFlow<Int>, val undoStarted: StateFlow<UndoStart?>,
/**
* Ticks when a drop ends with nothing landing — see
* `RescheduleViewModel.abandoned`. [move] answers before the write is
* resolved, so this is what tells a held copy to stop waiting.
*/
val abandoned: StateFlow<Int>,
/** Open an event in the edit form — the pointer-free route to the same change. */ /** Open an event in the edit form — the pointer-free route to the same change. */
val edit: (EventInstance) -> Unit, val edit: (EventInstance) -> Unit,
) { ) {
@@ -63,30 +61,42 @@ class EventMoveScope(
val LocalEventMove = compositionLocalOf<EventMoveScope?> { null } val LocalEventMove = compositionLocalOf<EventMoveScope?> { null }
private val NEVER_IN_FLIGHT = MutableStateFlow(false) private val moveTokens = AtomicLong()
private val NEVER_UNDONE = MutableStateFlow(0) /** A fresh identity for one drop or undo, unique for the process. */
fun nextMoveToken(): Long = moveTokens.incrementAndGet()
private val NEVER_ABANDONED = MutableStateFlow(0) /** How many abandoned drops [DropStates] remembers — far more than can be on screen at once. */
private const val ABANDONED_KEPT = 32
/** /** Per-drop progress, by token — see `RescheduleViewModel.drops`. */
* Whether a dropped event is still being written — false wherever moving is off. @Immutable
* The drag overlays hold a landed block on its target for this window. data class DropStates(
*/ val pending: Set<Long> = emptySet(),
@Composable val abandoned: Set<Long> = emptySet(),
fun moveInFlight(): Boolean { ) {
val flow = LocalEventMove.current?.inFlight ?: NEVER_IN_FLIGHT fun started(token: Long): DropStates = copy(pending = pending + token)
return flow.collectAsStateWithLifecycle().value
fun ended(token: Long, landed: Boolean): DropStates = copy(
pending = pending - token,
// Insertion-ordered, so the oldest go first.
abandoned = if (landed) abandoned else (abandoned + token).let { kept ->
if (kept.size > ABANDONED_KEPT) kept.drop(kept.size - ABANDONED_KEPT).toSet() else kept
},
)
} }
private val NO_DROPS = MutableStateFlow(DropStates())
private val NEVER_UNDONE = MutableStateFlow<UndoStart?>(null)
/** /**
* How many drops have ended with nothing landing. Read as a plain count so a * Every drop's progress — empty wherever moving is off. A drag overlay keeps
* drag overlay can capture it at the moment of the drop and tell, later, whether * the token of its own drop and holds its copy while that token is pending.
* *its own* drop was one of them.
*/ */
@Composable @Composable
fun abandonedMoves(): Int { fun dropStates(): DropStates {
val flow = LocalEventMove.current?.abandoned ?: NEVER_ABANDONED val flow = LocalEventMove.current?.drops ?: NO_DROPS
return flow.collectAsStateWithLifecycle().value return flow.collectAsStateWithLifecycle().value
} }
@@ -95,17 +105,53 @@ fun abandonedMoves(): Int {
* this composable came on screen. * this composable came on screen.
*/ */
@Composable @Composable
fun OnUndoStarted(onUndo: () -> Unit) { fun OnUndoStarted(onUndo: (UndoStart) -> Unit) {
val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE
val tick by flow.collectAsStateWithLifecycle() val start by flow.collectAsStateWithLifecycle()
var seen by remember { mutableIntStateOf(tick) } var seen by remember { mutableStateOf(start) }
LaunchedEffect(tick) { LaunchedEffect(start) {
if (tick == seen) return@LaunchedEffect val started = start
seen = tick if (started == null || started == seen) return@LaunchedEffect
onUndo() seen = started
onUndo(started)
} }
} }
/**
* The life of a drop parked by a newer pickup (#494): held while its [token]
* is pending, then a beat after the grid [seated] it — or [timeoutMillis] if it
* never does — handed over and faded, then released. Returns the copy's alpha.
* An abandoned drop skips the wait; nothing is coming to seat it.
*/
@Composable
fun rememberHeldSettle(
token: Long,
seated: Boolean,
timeoutMillis: Long,
onHandOver: () -> Unit,
onRelease: () -> Unit,
): Float {
val drops = dropStates()
val pending = token in drops.pending
val abandoned = token in drops.abandoned
val handOver by rememberUpdatedState(onHandOver)
val release by rememberUpdatedState(onRelease)
var fading by remember(token) { mutableStateOf(false) }
LaunchedEffect(token, pending, abandoned, seated) {
if (pending) return@LaunchedEffect
if (!abandoned) delay(if (seated) SETTLE_GRACE_MILLIS else timeoutMillis)
fading = true
handOver()
delay(SETTLE_FADE_MILLIS.toLong())
release()
}
return animateFloatAsState(
targetValue = if (fading) 0f else 1f,
animationSpec = tween(SETTLE_FADE_MILLIS),
label = "held-settle",
).value
}
/** Opacity the source block keeps while its floating copy travels. */ /** Opacity the source block keeps while its floating copy travels. */
const val GHOST_ALPHA: Float = 0.3f const val GHOST_ALPHA: Float = 0.3f
@@ -21,8 +21,8 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.domain.isOutlined
/** Stroke an invitation you have not answered is outlined with. */ /** Stroke an invitation you have not answered is outlined with. */
val EVENT_OUTLINE_WIDTH = 1.dp val EVENT_OUTLINE_WIDTH = 1.dp
@@ -60,13 +60,13 @@ fun timedBlockCuts(continuesBefore: Boolean, continuesAfter: Boolean): ChipCuts
/** /**
* How one event's chip or block is painted, which turns on your answer to its * How one event's chip or block is painted, which turns on your answer to its
* invitation (#180, #230). * invitation (#180, #230) and on the event's own status (#388).
* *
* An invitation you have not answered is drawn as an outline: its calendar's * An invitation you have not answered, or a tentative event, is drawn as an
* colour on the border and on the title, over the surface the chip sits on. So * outline: its calendar's colour on the border and on the title, over the
* it holds a chip's shape and a chip's weight without claiming the filled * surface the chip sits on. So it holds a chip's shape and a chip's weight
* container an event you are going to gets. A declined one keeps the fill and * without claiming the filled container an event you are going to gets. A
* its strike-through. * declined or cancelled one keeps the fill and its strike-through.
*/ */
@Immutable @Immutable
data class EventPaint( data class EventPaint(
@@ -87,7 +87,7 @@ data class EventPaint(
@Composable @Composable
fun eventPaint(event: EventInstance, dark: Boolean): EventPaint { fun eventPaint(event: EventInstance, dark: Boolean): EventPaint {
val soften = LocalSoftenColors.current val soften = LocalSoftenColors.current
if (event.response == EventResponse.Invited) { if (event.isOutlined) {
// Harmonised even when the setting is off. Raw mode exists so a filled // Harmonised even when the setting is off. Raw mode exists so a filled
// container matches what the sync source paints, and an outlined chip // container matches what the sync source paints, and an outlined chip
// has no container — what it has is coloured *text*, which needs the // has no container — what it has is coloured *text*, which needs the
@@ -117,7 +117,7 @@ fun eventPaint(event: EventInstance, dark: Boolean): EventPaint {
outline = null, outline = null,
titleInk = eventInk(fill, alpha = TITLE_INK_ALPHA), titleInk = eventInk(fill, alpha = TITLE_INK_ALPHA),
secondaryInk = eventInk(fill, alpha = SECONDARY_INK_ALPHA), secondaryInk = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
decoration = declinedDecoration(event.isDeclined), decoration = declinedDecoration(event.isCrossedOut),
titleWeight = null, titleWeight = null,
) )
} }
@@ -3,15 +3,12 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
@@ -45,33 +42,17 @@ fun inlineTimeLabel(time: String?, title: String, timeInk: Color): AnnotatedStri
append(title) append(title)
} }
/**
* The characters of title that have to survive a time prefix for it to be worth
* its place — lowercase Latin of average advance, priced at the surface's own
* text style rather than guessed in dp.
*/
private const val TITLE_SAMPLE = "notepad"
/** The widest wall-clock time in either convention: two-digit hour, and a meridiem in 12-hour. */ /** The widest wall-clock time in either convention: two-digit hour, and a meridiem in 12-hour. */
private const val SAMPLE_HOUR = 12 private const val SAMPLE_HOUR = 12
private const val SAMPLE_MINUTE = 45 private const val SAMPLE_MINUTE = 45
/** /**
* The narrowest run of [style] text that may carry a time in front of a title: * The widest time the current convention writes, for pricing a time prefix by
* wide enough for the widest time in the current convention plus [TITLE_SAMPLE]. * measurement: it follows the 12/24-hour setting and the locale's own format.
*
* Measured rather than a device breakpoint, so it follows the font scale, the
* 12/24-hour setting, the locale's own time format and whatever else has
* already been taken off the width.
*/ */
@Composable @Composable
fun rememberInlineTimeWidth(style: TextStyle): Dp { fun rememberWidestTimeSample(): String {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val locale = currentLocale() val locale = currentLocale()
val sample = formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, LocalUse24HourFormat.current, locale) val is24Hour = LocalUse24HourFormat.current
return remember(sample, style, density, measurer) { return remember(is24Hour, locale) { formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, is24Hour, locale) }
val label = inlineTimeLabel(sample, TITLE_SAMPLE, Color.Unspecified)
with(density) { measurer.measure(label, style).size.width.toDp() }
}
} }
@@ -56,7 +56,8 @@ private const val DIMMED_HOUR_ALPHA = 0.3f
* The timeline's hour gutter. Scrolls in sync with the day columns through the * 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 * 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 * the hour labels and floats a badge with the drag's current start time at the
* row the block would land on. * row the block would land on. Its content ends in the same [FabClearance]
* the day columns do, or the two drift apart at the bottom of the scroll.
*/ */
@Composable @Composable
fun HourGutter( fun HourGutter(
@@ -86,7 +87,8 @@ fun HourGutter(
.width(GUTTER_WIDTH) .width(GUTTER_WIDTH)
.padding(start = GUTTER_CONTENT_START_INSET) .padding(start = GUTTER_CONTENT_START_INSET)
.fillMaxHeight() .fillMaxHeight()
.verticalScroll(scrollState), .verticalScroll(scrollState)
.padding(bottom = FabClearance),
) { ) {
Column { Column {
(0 until 24).forEach { h -> (0 until 24).forEach { h ->
@@ -70,27 +70,24 @@ data class OutOfViewEvents(val above: TimedBlock?, val below: TimedBlock?) {
/** Which edge of the viewport a hidden event sits past. */ /** Which edge of the viewport a hidden event sits past. */
enum class ViewportEdge { Top, Bottom } enum class ViewportEdge { Top, Bottom }
/** Where [block]'s rendered bottom lands, in minutes — short blocks keep a floor. */
internal fun renderedEndMin(block: TimedBlock): Float =
maxOf(block.endMin.toFloat(), block.startMin + MIN_EVENT_FRACTION * 60f)
/** /**
* The blocks of one column lying out of a viewport spanning [topMin]..[bottomMin] * The blocks of one column lying out of a viewport spanning [topMin]..[bottomMin]
* (minutes of the day), nearest first. A block leaves this set as soon as any * (minutes of the day), nearest first. A block leaves this set as soon as any
* part of its rendered height enters the viewport. * part of its [renderedSpan] enters the viewport.
*/ */
fun outOfViewEvents( fun outOfViewEvents(
blocks: List<TimedBlock>, blocks: List<TimedBlock>,
topMin: Float, topMin: Float,
bottomMin: Float, bottomMin: Float,
): OutOfViewEvents { ): OutOfViewEvents {
val above = blocks val spans = blocks.map { it to it.renderedSpan() }
.filter { renderedEndMin(it) <= topMin } val above = spans
.maxWithOrNull(compareBy<TimedBlock>({ renderedEndMin(it) }, { it.startMin })) .filter { (_, span) -> span.bottomMin <= topMin }
val below = blocks .maxWithOrNull(compareBy({ it.second.bottomMin }, { it.first.startMin }))
.filter { it.startMin >= bottomMin } val below = spans
.minWithOrNull(compareBy<TimedBlock>({ it.startMin }, { -renderedEndMin(it) })) .filter { (_, span) -> span.topMin >= bottomMin }
return OutOfViewEvents(above, below) .minWithOrNull(compareBy({ it.second.topMin }, { -it.second.bottomMin }))
return OutOfViewEvents(above?.first, below?.first)
} }
/** /**
@@ -107,8 +104,9 @@ fun revealScrollTarget(
marginPx: Float, marginPx: Float,
maxScroll: Int, maxScroll: Int,
): Int { ): Int {
val startPx = block.startMin / 60f * hourPx val span = block.renderedSpan()
val endPx = renderedEndMin(block) / 60f * hourPx val startPx = span.topMin / 60f * hourPx
val endPx = span.bottomMin / 60f * hourPx
val target = when (edge) { val target = when (edge) {
ViewportEdge.Top -> startPx - marginPx ViewportEdge.Top -> startPx - marginPx
ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx) ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx)
@@ -293,6 +291,7 @@ private fun EdgeBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
block ?: return block ?: return
val span = remember(block) { block.renderedSpan() }
val color = eventFill(block.event.color, dark, LocalSoftenColors.current) val color = eventFill(block.event.color, dark, LocalSoftenColors.current)
val label = stringResource( val label = stringResource(
if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events, if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events,
@@ -303,8 +302,8 @@ private fun EdgeBar(
.height(EDGE_TOUCH_HEIGHT) .height(EDGE_TOUCH_HEIGHT)
.graphicsLayer { .graphicsLayer {
val gapPx = when (edge) { val gapPx = when (edge) {
ViewportEdge.Top -> scrollState.value - renderedEndMin(block) / 60f * hourPx ViewportEdge.Top -> scrollState.value - span.bottomMin / 60f * hourPx
ViewportEdge.Bottom -> block.startMin / 60f * hourPx - ViewportEdge.Bottom -> span.topMin / 60f * hourPx -
(scrollState.value + scrollState.viewportSize) (scrollState.value + scrollState.viewportSize)
} }
alpha = edgeBarAlpha(gapPx, fadeLeadPx) alpha = edgeBarAlpha(gapPx, fadeLeadPx)
@@ -1,22 +1,26 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
/** /**
* One shared state per pager page — a week, a day, a month — so a page and the * One shared state per pager page — a week, a day, a month — so a page and the
* screen's anchor read the same query, and the pages either side stay loaded * screen's anchor read the same query, and the pages either side stay loaded
* across a swipe. * across a swipe.
* *
* Every entry shares on its own job, cancelled when [keep] lets it go: a * An entry queries only while something collects it, stopping [STOP_TIMEOUT_MILLIS]
* `stateIn` on the ViewModel's scope would stay running, holding its last state, * after the last collector leaves, and starts again when one comes back. Letting
* until the ViewModel itself was cleared. Main thread only. * an entry go only forgets it: a page still holding it keeps a working state
* however far the pager composes or prefetches, and however long the screen sat
* stopped (#440, #454). A forgotten entry nobody collects holds no coroutine, so
* paging through a year doesn't keep a year of queries around. Main thread only.
* *
* @param keep whether a cached key is still worth holding once [get] asks for another. * @param keep whether a cached key is still worth holding once [get] asks for another.
*/ */
@@ -26,28 +30,54 @@ class PageStateCache<K, S>(
private val keep: (cached: K, requested: K) -> Boolean, private val keep: (cached: K, requested: K) -> Boolean,
private val load: (K) -> Flow<S>, private val load: (K) -> Flow<S>,
) { ) {
private class Entry<S>(val state: StateFlow<S>, val job: Job) private val entries = HashMap<K, Entry>()
private val entries = HashMap<K, Entry<S>>()
/** The number of entries held, for tests. */ /** The number of entries held, for tests. */
internal val size: Int get() = entries.size internal val size: Int get() = entries.size
fun get(key: K): StateFlow<S> { fun get(key: K): StateFlow<S> {
val iterator = entries.iterator() // One still collected stays, so the anchor asking for it again shares its query.
while (iterator.hasNext()) { entries.entries.removeAll { (cached, entry) ->
val (cached, entry) = iterator.next() cached != key && !keep(cached, key) && !entry.isCollected
if (cached != key && !keep(cached, key)) { }
entry.job.cancel() return entries.getOrPut(key) { Entry(key) }
iterator.remove() }
@OptIn(ExperimentalForInheritanceCoroutinesApi::class)
private inner class Entry(private val key: K) : StateFlow<S> {
private val state = MutableStateFlow(initial)
private var collectors = 0
private var loading: Job? = null
private var stopping: Job? = null
val isCollected: Boolean get() = collectors > 0
override val value: S get() = state.value
override val replayCache: List<S> get() = state.replayCache
override suspend fun collect(collector: FlowCollector<S>): Nothing {
collectors++
stopping?.cancel()
stopping = null
if (loading?.isActive != true) {
loading = scope.launch { load(key).collect { state.value = it } }
}
try {
state.collect(collector)
} finally {
if (--collectors == 0) {
stopping = scope.launch {
delay(STOP_TIMEOUT_MILLIS)
loading?.cancel()
loading = null
}
}
} }
} }
return entries.getOrPut(key) { }
val job = SupervisorJob(scope.coroutineContext[Job])
Entry( private companion object {
state = load(key).stateWhileSubscribed(scope + job, initial), /** Long enough to ride out a configuration change, as `stateWhileSubscribed` does. */
job = job, const val STOP_TIMEOUT_MILLIS = 5_000L
)
}.state
} }
} }
@@ -30,7 +30,7 @@ fun EventInstance.isPastOn(
day: LocalDate, day: LocalDate,
now: Instant, now: Instant,
zone: TimeZone = TimeZone.currentSystemDefault(), zone: TimeZone = TimeZone.currentSystemDefault(),
): Boolean = hasEnded(now) || day < now.toLocalDateTime(zone).date ): Boolean = hasEnded(now, zone) || day < now.toLocalDateTime(zone).date
/** Opacity applied to a completed/past event chip when it is dimmed. */ /** Opacity applied to a completed/past event chip when it is dimmed. */
const val EventDimAlpha = 0.4f const val EventDimAlpha = 0.4f
@@ -41,19 +41,19 @@ const val EventDimAlpha = 0.4f
* is still something the organiser expects you at. Null for everything else, so * is still something the organiser expects you at. Null for everything else, so
* it drops straight into a `Text`'s `textDecoration`. * it drops straight into a `Text`'s `textDecoration`.
*/ */
fun declinedDecoration(isDeclined: Boolean): TextDecoration? = fun declinedDecoration(isCrossedOut: Boolean): TextDecoration? =
if (isDeclined) TextDecoration.LineThrough else null if (isCrossedOut) TextDecoration.LineThrough else null
/** [declinedDecoration] for the rows that take styled text rather than a `String`. */ /** [declinedDecoration] for the rows that take styled text rather than a `String`. */
fun declinedTitle(title: String, isDeclined: Boolean): AnnotatedString = if (isDeclined) { fun declinedTitle(title: String, isCrossedOut: Boolean): AnnotatedString = if (isCrossedOut) {
AnnotatedString(title, SpanStyle(textDecoration = TextDecoration.LineThrough)) AnnotatedString(title, SpanStyle(textDecoration = TextDecoration.LineThrough))
} else { } else {
AnnotatedString(title) AnnotatedString(title)
} }
/** [declinedTitle] over already-styled text, e.g. a search hit's marked runs. */ /** [declinedTitle] over already-styled text, e.g. a search hit's marked runs. */
fun declinedTitle(title: AnnotatedString, isDeclined: Boolean): AnnotatedString = fun declinedTitle(title: AnnotatedString, isCrossedOut: Boolean): AnnotatedString =
if (!isDeclined) { if (!isCrossedOut) {
title title
} else { } else {
buildAnnotatedString { buildAnnotatedString {
@@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.time.ZoneSource import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.domain.canSaveApartFromSeries
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormProblem import de.jeanlucmakiola.calendula.domain.EventFormProblem
@@ -31,6 +32,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -39,12 +41,21 @@ import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant import kotlin.time.Instant
import kotlin.time.TimeMark
import kotlin.time.TimeSource
import javax.inject.Inject import javax.inject.Inject
private const val MILLIS_PER_MINUTE = 60_000 private const val MILLIS_PER_MINUTE = 60_000
private const val MINUTES_PER_DAY = 24 * 60 private const val MINUTES_PER_DAY = 24 * 60
/** How many drops may wait behind the one in flight before more are refused. */
private const val MAX_QUEUED = 8
/** How long after a write its event's drawn times may still be the old ones. */
private val RECENT_WRITE = 3.seconds
/** Where a dragged event should land. */ /** Where a dragged event should land. */
sealed interface MoveTarget { sealed interface MoveTarget {
/** A new start instant — a timeline drag, which moves time and day at once. */ /** A new start instant — a timeline drag, which moves time and day at once. */
@@ -57,7 +68,10 @@ sealed interface MoveTarget {
*/ */
data class ByDays(val days: Int) : MoveTarget data class ByDays(val days: Int) : MoveTarget
/** New start and end instants — a timeline resize, which changes the length. */ /**
* New start and end instants — a timeline resize, which changes the length,
* or an all-day event dragged into the time grid, which becomes timed.
*/
data class Span(val start: Instant, val end: Instant) : MoveTarget data class Span(val start: Instant, val end: Instant) : MoveTarget
} }
@@ -70,6 +84,10 @@ data class MoveRequest(
val beginMillis: Long, val beginMillis: Long,
val endMillis: Long, val endMillis: Long,
val target: MoveTarget, val target: MoveTarget,
/** Whether the dragged occurrence is all-day, which decides the zone its day is read in. */
val isAllDay: Boolean,
/** The drop's own identity, from [nextMoveToken], which the view holding its copy waits on. */
val token: Long,
) )
/** /**
@@ -88,8 +106,13 @@ data class MoveUndo(
val moved: EventForm, val moved: EventForm,
/** The form as it stood before the move. */ /** The form as it stood before the move. */
val restored: EventForm, val restored: EventForm,
/** The token of the drop this undoes, so the view that drew it carries the right chip back. */
val dropToken: Long,
) )
/** An undo write that just began: its own [token], and the [dropToken] of the drop it undoes. */
data class UndoStart(val token: Long, val dropToken: Long)
sealed interface MoveOutcome { sealed interface MoveOutcome {
/** /**
* Written. [startMillis] is where the dragged occurrence now begins, for the * Written. [startMillis] is where the dragged occurrence now begins, for the
@@ -111,9 +134,28 @@ sealed interface MoveOutcome {
/** The event vanished (sync, another device) between the read and the write. */ /** The event vanished (sync, another device) between the read and the write. */
data object Gone : MoveOutcome data object Gone : MoveOutcome
/** Drops that waited behind a scope dialog, cancelled rather than run after it (#494). */
data object Skipped : MoveOutcome
/** More drops waiting than the queue holds; this one was not taken. */
data object QueueFull : MoveOutcome
/**
* The drop's recurring occurrence may no longer be where it was picked up —
* its series was just written — and can't be followed there, so it is not
* written (#494).
*/
data object Stale : MoveOutcome
/** The drop would push the series past its own `UNTIL`, generating nothing. */ /** The drop would push the series past its own `UNTIL`, generating nothing. */
data object BlockedSeriesEnd : MoveOutcome data object BlockedSeriesEnd : MoveOutcome
/**
* A cancelled series only moves as a whole, and this drop would change which
* days it falls on (#388).
*/
data object BlockedCancelledSeries : MoveOutcome
data object Failed : MoveOutcome data object Failed : MoveOutcome
} }
@@ -142,36 +184,32 @@ class RescheduleViewModel @Inject constructor(
private val _inFlight = MutableStateFlow(false) private val _inFlight = MutableStateFlow(false)
/** /** True while any drop or undo runs or waits — what the confirmation chip holds for. */
* 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() val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
private val _abandoned = MutableStateFlow(0) private val _drops = MutableStateFlow(DropStates())
/** /**
* Ticks when a drop ends with nothing on the target day: refused outright, * Each drop's own state, by its [MoveRequest.token]: pending from the moment
* refused before the write, written and failed, or its scope dialog * it is accepted until its write settles, and abandoned once it ends with
* dismissed. [move] answers before any of that is known, so without this * nothing on the target day — refused, failed, or its scope dialog dismissed.
* signal the view that drew the drop held its copy out for the full settle * Per drop, so a queued drop's held copy waits on its own write and is never
* timeout waiting for a grid that was never going to draw it (#253). * let go by another drop's failure (#253, #494).
*/ */
val abandoned: StateFlow<Int> = _abandoned.asStateFlow() val drops: StateFlow<DropStates> = _drops.asStateFlow()
private val _undoStarted = MutableStateFlow(0) private val _undoStarted = MutableStateFlow<UndoStart?>(null)
/** /**
* Ticks the moment an undo write begins, before the provider has anything to * An undo write the moment it begins, before the provider has anything to
* re-read, so the view that drew the drop can carry its chip back rather than * re-read, so the view that drew the drop can carry its chip back rather than
* let it reappear on the old day. * let it reappear on the old day. Its fate then reads off [drops].
*/ */
val undoStarted: StateFlow<Int> = _undoStarted.asStateFlow() val undoStarted: StateFlow<UndoStart?> = _undoStarted.asStateFlow()
/** /**
* Set from the moment a drop is accepted until its write settles. Two drops * Set while a drop is being prepared, waits on its scope dialog, or is
* of the same recurring event inside that window would compute their shift * written. Drops arriving in that window wait in [queue] and run in order.
* from the same pre-move occurrence, and the shifts would compound.
*/ */
private var busy = false private var busy = false
set(value) { set(value) {
@@ -179,6 +217,22 @@ class RescheduleViewModel @Inject constructor(
_inFlight.value = value _inFlight.value = value
} }
/** Whether a move has been announced since the pipeline was last idle — see [report]. */
private var movedThisRun = false
/** Outcomes waiting for the chip, shown one after another as each is consumed. */
private val notices = ArrayDeque<MoveOutcome>()
/**
* When each event's series (by id) or single occurrence (by id and begin)
* was last written. The grid draws the old times until it re-reads, so a
* drop on a recurring event written moments ago may carry stale times (#494).
*/
private val written = mutableMapOf<Pair<Long, Long?>, TimeMark>()
/** Drops that arrived while another was in flight, oldest first (#494). */
private val queue = ArrayDeque<MoveRequest>()
/** /**
* The calendars whose events may be dragged. Nothing below the UI guards * 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. * this, so a block outside this set registers no drag gesture at all.
@@ -203,43 +257,63 @@ class RescheduleViewModel @Inject constructor(
val canRealign: Boolean, val canRealign: Boolean,
/** The series row's DTSTART date after the move — what its `UNTIL` must clear. */ /** The series row's DTSTART date after the move — what its `UNTIL` must clear. */
val newAnchorDate: LocalDate, val newAnchorDate: LocalDate,
) ) {
/** A timed event's new length, rather than an all-day one dragged into the grid. */
val isResize: Boolean get() = request.target is MoveTarget.Span && !original.isAllDay
}
/** /**
* Take a drop, unless one is already being written. False means nothing will * Take a drop: run it now, or queue it behind the ones in flight. False means
* be written; [abandoned] ticks for that refusal too, so a held copy is let * nothing will be written — the queue is full — and the drop is marked
* go by the one signal rather than by every caller remembering to. * abandoned in [drops], so its held copy is let go by the one signal rather
* than by every caller remembering to.
* *
* The Boolean is diagnostic — the tests read it, [EventMoveScope] does not * The Boolean is diagnostic — the tests read it, [EventMoveScope] does not
* hand it on. A caller that gated on it would be back to releasing its copy * hand it on. It answers before the write is resolved anyway.
* from two places, and it answers before the write is resolved anyway.
*/ */
fun move(request: MoveRequest): Boolean { fun move(request: MoveRequest): Boolean {
if (busy || _scopePrompt.value != null) { if (queue.size >= MAX_QUEUED) {
_abandoned.value += 1 _drops.update { it.ended(request.token, landed = false) }
report(MoveOutcome.QueueFull)
return false return false
} }
if (_scopePrompt.value != null) {
_drops.update { it.ended(request.token, landed = false) }
report(MoveOutcome.Skipped)
return false
}
_drops.update { it.started(request.token) }
if (busy) queue.addLast(request) else run(request)
return true
}
private fun run(request: MoveRequest) {
busy = true busy = true
viewModelScope.launch { viewModelScope.launch {
val prepared = prepare(request) val prepared = prepare(request)
if (prepared == null) { if (prepared == null) {
_abandoned.value += 1 settle(request.token, landed = false)
busy = false
return@launch return@launch
} }
if (!prepared.isRecurring) { // A cancelled series only moves as a whole (#388).
write(prepared, RecurringWriteScope.AllEvents) val seriesOnly = !prepared.original.canSaveApartFromSeries()
busy = false if (!prepared.isRecurring || (seriesOnly && prepared.canRealign)) {
settle(request.token, write(prepared, RecurringWriteScope.AllEvents))
} else if (seriesOnly) {
report(MoveOutcome.BlockedCancelledSeries)
settle(request.token, landed = false)
} else { } else {
// Still busy: the scope dialog is now the thing in flight. // Still busy: the scope dialog is now the thing in flight. Drops
// waiting behind it are cancelled, not held behind a question
// about another event.
skipQueued()
pending = prepared pending = prepared
_scopePrompt.value = MoveScopePrompt( _scopePrompt.value = MoveScopePrompt(
occurrenceOnly = !prepared.canRealign, occurrenceOnly = !prepared.canRealign,
resize = request.target is MoveTarget.Span, resize = prepared.isResize,
) )
} }
} }
return true
} }
/** Answer the scope dialog. */ /** Answer the scope dialog. */
@@ -250,35 +324,92 @@ class RescheduleViewModel @Inject constructor(
pending = null pending = null
_scopePrompt.value = null _scopePrompt.value = null
viewModelScope.launch { viewModelScope.launch {
write(prepared, scope) settle(prepared.request.token, write(prepared, scope))
busy = false
} }
} }
/** Dismiss the scope dialog without writing. */ /** Dismiss the scope dialog without writing. */
fun cancelScope() { fun cancelScope() {
val prepared = pending ?: return
pending = null pending = null
busy = false
_scopePrompt.value = null _scopePrompt.value = null
_abandoned.value += 1 settle(prepared.request.token, landed = false)
}
/** End the drop [token], then run the next queued one, if any. */
private fun settle(token: Long, landed: Boolean) {
_drops.update { it.ended(token, landed) }
// Straight on to the next, so busy never reads false between two drops.
val next = queue.removeFirstOrNull()
if (next != null) {
run(next)
} else {
busy = false
movedThisRun = false
}
}
private fun skipQueued() {
if (queue.isEmpty()) return
val skipped = queue.map { it.token }
queue.clear()
_drops.update { states -> skipped.fold(states) { acc, token -> acc.ended(token, landed = false) } }
report(MoveOutcome.Skipped)
}
/**
* Queue [outcome] for the chip. While drops run back to back only the first
* move is announced, so its Undo stays on offer; every failure gets its own
* turn after it, since the grid shows what moved but only the chip says what
* didn't. A later write to the announced move's event takes that Undo away,
* as putting the event back would undo the later move too.
*/
private fun report(outcome: MoveOutcome, eventId: Long? = null) {
if (outcome is MoveOutcome.Moved) {
if (movedThisRun) {
dropUndoFor(eventId)
return
}
movedThisRun = true
} else if (outcome == (notices.lastOrNull() ?: _outcome.value)) {
return
}
if (_outcome.value == null) _outcome.value = outcome else notices.addLast(outcome)
}
private fun dropUndoFor(eventId: Long?) {
fun MoveOutcome.withoutUndo(): MoveOutcome =
if (this is MoveOutcome.Moved && undo?.eventId == eventId) copy(undo = null) else this
_outcome.value = _outcome.value?.withoutUndo()
val rest = notices.map { it.withoutUndo() }
notices.clear()
notices.addAll(rest)
}
private fun markWritten(eventId: Long, beginMillis: Long?) {
written[eventId to beginMillis] = TimeSource.Monotonic.markNow()
}
private fun writtenRecently(eventId: Long, beginMillis: Long): Boolean {
written.entries.removeAll { it.value.elapsedNow() > RECENT_WRITE }
return (eventId to null) in written || (eventId to beginMillis) in written
} }
/** /**
* Put a completed move back where it came from. The outcome stands until the * 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 * inverse write reports back, so the one chip changes what it says rather
* than closing and reopening. False when another write is already running — * than closing and reopening. False when another write is already running —
* the chip hides its action for that window, so this is the backstop. * the chip hides its action for that window, so this is the backstop. An
* * undo is a journey of its own: [undoStarted] names its token, and [drops]
* [abandoned] ticks once an inverse that *started* fails to land: [undoStarted] * reports whether it landed.
* has sent the chip back on its journey by then and nothing else will seat it.
* A refusal ticks nothing — it launched no journey of its own, and the counter
* is global, so a tick here would release some other drop's held copy before
* the grid has drawn it.
*/ */
fun undo(undo: MoveUndo): Boolean { fun undo(undo: MoveUndo): Boolean {
if (busy) return false if (busy) return false
val token = nextMoveToken()
_drops.update { it.started(token) }
busy = true busy = true
_undoStarted.value += 1 _undoStarted.value = UndoStart(token, undo.dropToken)
markWritten(undo.eventId, null)
viewModelScope.launch { viewModelScope.launch {
_outcome.value = try { _outcome.value = try {
repository.updateEvent(undo.eventId, undo.moved, undo.restored) repository.updateEvent(undo.eventId, undo.moved, undo.restored)
@@ -292,33 +423,40 @@ class RescheduleViewModel @Inject constructor(
} catch (e: Exception) { } catch (e: Exception) {
MoveOutcome.Failed MoveOutcome.Failed
} }
// Read off the outcome, the way [write] does — no second copy of the settle(token, landed = _outcome.value is MoveOutcome.Undone)
// same fact to drift from it.
if (_outcome.value !is MoveOutcome.Undone) _abandoned.value += 1
busy = false
} }
return true return true
} }
/** Clear the outcome once the screen has shown it. */ /** Clear the outcome once the screen has shown it, bringing on the next one waiting. */
fun consumeOutcome() { fun consumeOutcome() {
_outcome.value = null _outcome.value = notices.removeFirstOrNull()
} }
private suspend fun prepare(request: MoveRequest): PreparedMove? { private suspend fun prepare(dropped: MoveRequest): PreparedMove? {
val zone = zoneSource.current()
val detail = try { val detail = try {
repository.eventDetail(request.eventId) repository.eventDetail(dropped.eventId)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: NoSuchEventException) { } catch (e: NoSuchEventException) {
_outcome.value = MoveOutcome.Gone report(MoveOutcome.Gone)
return null return null
} catch (e: Exception) { } catch (e: Exception) {
_outcome.value = MoveOutcome.Failed report(MoveOutcome.Failed)
return null return null
} }
// The drop carries the times the grid drew, which an earlier write may
// have moved on since (#494).
val request = if (detail.rrule == null || detail.isException) {
followed(dropped, detail, zone)
} else if (writtenRecently(dropped.eventId, dropped.beginMillis)) {
report(MoveOutcome.Stale)
return null
} else {
dropped
}
val zone = zoneSource.current()
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone) val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
val shifted = when (val target = request.target) { val shifted = when (val target = request.target) {
is MoveTarget.Start -> original.shiftedTo(target.instant, zone) is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
@@ -331,7 +469,7 @@ class RescheduleViewModel @Inject constructor(
// The UNTIL check is deferred to the write, which knows how far the move // The UNTIL check is deferred to the write, which knows how far the move
// reaches and so which date has to clear it. // reaches and so which date has to clear it.
if ((shifted.problems(zone) - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) { if ((shifted.problems(zone) - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
_outcome.value = MoveOutcome.Failed report(MoveOutcome.Failed)
return null return null
} }
@@ -376,14 +514,14 @@ class RescheduleViewModel @Inject constructor(
) )
} }
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) { /** Write [prepared] in [scope]; true when it landed. */
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope): Boolean {
val request = prepared.request val request = prepared.request
if (endsBeforeItStarts(prepared, scope)) { if (endsBeforeItStarts(prepared, scope)) {
_outcome.value = MoveOutcome.BlockedSeriesEnd report(MoveOutcome.BlockedSeriesEnd)
_abandoned.value += 1 return false
return
} }
_outcome.value = try { val outcome = try {
when { when {
!prepared.isRecurring || scope == RecurringWriteScope.AllEvents -> !prepared.isRecurring || scope == RecurringWriteScope.AllEvents ->
repository.updateEvent(request.eventId, prepared.original, prepared.updated) repository.updateEvent(request.eventId, prepared.original, prepared.updated)
@@ -418,9 +556,37 @@ class RescheduleViewModel @Inject constructor(
} catch (e: Exception) { } catch (e: Exception) {
MoveOutcome.Failed MoveOutcome.Failed
} }
// Nothing landed, so the copy the view is holding has nothing to fade if (outcome is MoveOutcome.Moved) {
// into — let it go now rather than at the end of the settle timeout. markWritten(request.eventId, request.beginMillis.takeIf { scope == RecurringWriteScope.ThisEvent && prepared.isRecurring })
if (_outcome.value !is MoveOutcome.Moved) _abandoned.value += 1 }
report(outcome, request.eventId)
return outcome is MoveOutcome.Moved
}
/**
* A one-off [dropped] rebased onto where its event now is, so the last drop
* wins: absolute targets stand as they are, and a whole-day shift is re-aimed
* at the day it was dropped on. Unchanged when the event hasn't moved.
*/
private fun followed(dropped: MoveRequest, detail: EventDetail, zone: TimeZone): MoveRequest {
val now = detail.instance
val target = when (val target = dropped.target) {
is MoveTarget.ByDays -> {
// Each day read the way its own kind anchors it: all-day at UTC.
val aimed = Instant.fromEpochMilliseconds(dropped.beginMillis)
.toLocalDateTime(if (dropped.isAllDay) TimeZone.UTC else zone).date
.plus(target.days, DateTimeUnit.DAY)
val from = now.start.toLocalDateTime(if (now.isAllDay) TimeZone.UTC else zone).date
MoveTarget.ByDays((aimed.toEpochDays() - from.toEpochDays()).toInt())
}
else -> target
}
return dropped.copy(
beginMillis = now.start.toEpochMilliseconds(),
endMillis = now.end.toEpochMilliseconds(),
target = target,
isAllDay = now.isAllDay,
)
} }
/** /**
@@ -459,9 +625,9 @@ class RescheduleViewModel @Inject constructor(
val anchorZone = if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone) val anchorZone = if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone)
val anchorMinute = detail.instance.start.toLocalDateTime(anchorZone) val anchorMinute = detail.instance.start.toLocalDateTime(anchorZone)
.time.toMillisecondOfDay() / MILLIS_PER_MINUTE .time.toMillisecondOfDay() / MILLIS_PER_MINUTE
val delta = ( // An all-day form's time is a placeholder; its anchor sits at midnight.
shifted.start.time.toMillisecondOfDay() - original.start.time.toMillisecondOfDay() val fromMillis = if (original.isAllDay) 0 else original.start.time.toMillisecondOfDay()
) / MILLIS_PER_MINUTE val delta = (shifted.start.time.toMillisecondOfDay() - fromMillis) / MILLIS_PER_MINUTE
return anchorMinute + delta in 0 until MINUTES_PER_DAY return anchorMinute + delta in 0 until MINUTES_PER_DAY
} }
@@ -487,6 +653,7 @@ class RescheduleViewModel @Inject constructor(
eventId = prepared.request.eventId, eventId = prepared.request.eventId,
moved = prepared.updated, moved = prepared.updated,
restored = prepared.original, restored = prepared.original,
dropToken = prepared.request.token,
) )
} else { } else {
null null
@@ -494,7 +661,7 @@ class RescheduleViewModel @Inject constructor(
/** Where a resized occurrence now ends; null for a move, which keeps its length. */ /** Where a resized occurrence now ends; null for a move, which keeps its length. */
private fun resizedEndMillis(prepared: PreparedMove): Long? { private fun resizedEndMillis(prepared: PreparedMove): Long? {
if (prepared.request.target !is MoveTarget.Span) return null if (!prepared.isResize) return null
val form = prepared.updated val form = prepared.updated
return form.endInstant(form.resolvedZone(zoneSource.current())).toEpochMilliseconds() return form.endInstant(form.resolvedZone(zoneSource.current())).toEpochMilliseconds()
} }
@@ -101,9 +101,10 @@ fun TimeZonePickerDialog(
onDismiss = onDismiss, onDismiss = onDismiss,
scrollable = false, scrollable = false,
) { ) {
SearchField( PickerSearchField(
query = query, query = query,
onQueryChange = { query = it }, onQueryChange = { query = it },
placeholder = stringResource(R.string.event_edit_timezone_search),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) )
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) { LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
@@ -181,9 +182,10 @@ fun TimeZonePickerDialog(
* which has a top bar to put it in) because the picker's bar is the title. * which has a top bar to put it in) because the picker's bar is the title.
*/ */
@Composable @Composable
private fun SearchField( internal fun PickerSearchField(
query: String, query: String,
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val keyboard = LocalSoftwareKeyboardController.current val keyboard = LocalSoftwareKeyboardController.current
@@ -206,7 +208,7 @@ private fun SearchField(
InlineTextField( InlineTextField(
value = query, value = query,
onValueChange = onQueryChange, onValueChange = onQueryChange,
placeholder = stringResource(R.string.event_edit_timezone_search), placeholder = placeholder,
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Search, imeAction = ImeAction.Search,
onImeAction = { keyboard?.hide() }, onImeAction = { keyboard?.hide() },
@@ -20,6 +20,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -60,7 +61,6 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
@@ -78,6 +78,9 @@ import kotlin.time.Instant
/** Start granularity a dragged block snaps to, matching the big calendar apps. */ /** Start granularity a dragged block snaps to, matching the big calendar apps. */
const val DRAG_SNAP_MINUTES: Int = 15 const val DRAG_SNAP_MINUTES: Int = 15
/** How far below a strip drag's timed block top the finger holds it, in minutes. */
private const val STRIP_GRAB_MINUTES = 15
/** How close to a timeline edge the finger must get before the view scrolls. */ /** How close to a timeline edge the finger must get before the view scrolls. */
private val AUTO_SCROLL_EDGE = 64.dp private val AUTO_SCROLL_EDGE = 64.dp
@@ -126,6 +129,29 @@ data class TimelineDrag(
val slot: Triple<LocalDate, Int, Int> get() = Triple(date, startMin, eventSpanMin) val slot: Triple<LocalDate, Int, Int> get() = Triple(date, startMin, eventSpanMin)
} }
/**
* An event lifted off the all-day strip: held over the strip it moves [dayShift]
* days and stays whole-day, held over the time grid it becomes a timed block.
*/
data class StripDrag(val event: EventInstance, val dayShift: Int = 0, val overGrid: Boolean = false)
/** Where a strip drag asks its event to go. */
sealed interface StripDrop {
val event: EventInstance
/** The drop's identity, which its held copy waits on. */
val token: Long
data class Days(override val event: EventInstance, val days: Int, override val token: Long) : StripDrop
data class Timed(
override val event: EventInstance,
val start: Instant,
val end: Instant,
override val token: Long,
) : StripDrop
}
/** The event a long press left selected (#141). */ /** The event a long press left selected (#141). */
data class TimelineSelection( data class TimelineSelection(
val event: EventInstance, val event: EventInstance,
@@ -249,9 +275,10 @@ internal fun dragPieceBounds(
): DragPieceBounds { ): DragPieceBounds {
val top = if (slice.continuesBefore) 0f else insetPx val top = if (slice.continuesBefore) 0f else insetPx
val bottom = if (slice.continuesAfter) 0f else insetPx val bottom = if (slice.continuesAfter) 0f else insetPx
val full = maxOf(slice.spanMin / 60f * hourPx, MIN_EVENT_FRACTION * hourPx) val span = renderedSpan(slice.startMin, slice.startMin + slice.spanMin, slice.continuesAfter)
val full = (span.bottomMin - span.topMin) / 60f * hourPx
return DragPieceBounds( return DragPieceBounds(
top = slice.startMin / 60f * hourPx + top, top = span.topMin / 60f * hourPx + top,
height = (full - top - bottom).coerceAtLeast(0f), height = (full - top - bottom).coerceAtLeast(0f),
) )
} }
@@ -278,7 +305,9 @@ data class TimelineDrop(
val date: LocalDate, val date: LocalDate,
val startMin: Int, val startMin: Int,
/** Minutes from the event's start to the dragged block's top edge. */ /** Minutes from the event's start to the dragged block's top edge. */
val clipOffsetMin: Int = 0, val clipOffsetMin: Int,
/** The drop's identity, which its held copy waits on. */
val token: Long,
) )
/** The id a drafted block's stand-in event carries, which no provider row has (#153). */ /** The id a drafted block's stand-in event carries, which no provider row has (#153). */
@@ -309,19 +338,49 @@ internal fun draftStartMin(minutes: Float): Int =
internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone): TimedBlock? { internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone): TimedBlock? {
// Clipped to the day the way the grid lays out events, so it's drawn the same across a DST change (#387). // Clipped to the day the way the grid lays out events, so it's drawn the same across a DST change (#387).
// The end is exclusive, so a draft ending at midnight belongs to the day before. // The end is exclusive, so a draft ending at midnight belongs to the day before.
val dayEnd = date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
val start = maxOf(draft.start, date.atStartOfDayIn(zone)) val start = maxOf(draft.start, date.atStartOfDayIn(zone))
val end = minOf(draft.end, date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)) val end = minOf(draft.end, dayEnd)
if (end <= start) return null if (end <= start) return null
val startMin = wallClockMinutes(start, date, zone) val startMin = wallClockMinutes(start, date, zone)
val endMin = wallClockEndMinutes(startMin, start, end, date, zone).coerceAtMost(MINUTES_PER_DAY) val endMin = wallClockEndMinutes(startMin, start, end, date, zone).coerceAtMost(MINUTES_PER_DAY)
return TimedBlock(draft, startMin, endMin, lane = 0, laneCount = 1) return TimedBlock(draft, startMin, endMin, lane = 0, laneCount = 1, continuesAfter = draft.end > dayEnd)
} }
/** Which end of an event a resize handle moves (#141). */ /** Which end of an event a resize handle moves (#141). */
enum class ResizeEdge { Top, Bottom } enum class ResizeEdge { Top, Bottom }
/** A drop parked by a newer pickup while its write still runs — see [TimelineDragController.held]. */
data class HeldDrop(
val copy: TimelineDrag,
val token: Long,
val instanceId: Long,
/** The event row and start the drop came from, as [TimelineDragController.ghosts] matches them. */
val origin: Pair<Long, Instant>,
/** A resize's new end, which has to have landed too before the drop counts as seated. */
val end: Instant?,
/** Whether the grid now draws the drop at its landing slot. */
val seated: Boolean = false,
val handedOver: Boolean = false,
) {
fun ghosts(block: TimedBlock): Boolean = block.event.instanceId == instanceId ||
(block.event.eventId == origin.first && block.event.start == origin.second)
}
/** A strip drop parked by a newer one while its write still runs — see [TimelineDragController.heldStrips]. */
data class HeldStrip(val drag: StripDrag, val token: Long, val seated: Boolean = false)
/** An event's new start and end, as a resize leaves them. */
data class ResizedSpan(val start: Instant, val end: Instant)
/** Where a finished resize asks its event to begin and end. */ /** Where a finished resize asks its event to begin and end. */
data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant) data class TimelineResizeDrop(
val event: EventInstance,
val start: Instant,
val end: Instant,
/** The drop's identity, which its held copy waits on. */
val token: Long,
)
/** /**
* [event] resized by its [edge] to the span [startMin]..+[spanMin] from [date]'s * [event] resized by its [edge] to the span [startMin]..+[spanMin] from [date]'s
@@ -332,17 +391,16 @@ data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val
* it was drawn (see [repicked]). An end takes a pass the grid draws at the drop * it was drawn (see [repicked]). An end takes a pass the grid draws at the drop
* point, so the block settles under the finger. * point, so the block settles under the finger.
*/ */
internal fun resizedDrop( internal fun resizedSpan(
event: EventInstance, event: EventInstance,
edge: ResizeEdge?, edge: ResizeEdge?,
date: LocalDate, date: LocalDate,
startMin: Int, startMin: Int,
spanMin: Int, spanMin: Int,
zone: TimeZone, zone: TimeZone,
): TimelineResizeDrop { ): ResizedSpan {
val drawn = wallClockSpan(event, zone) val drawn = wallClockSpan(event, zone)
return TimelineResizeDrop( return ResizedSpan(
event = event,
start = if (edge == ResizeEdge.Top) { start = if (edge == ResizeEdge.Top) {
event.start.toFormTime(zone).repicked( event.start.toFormTime(zone).repicked(
wallClockTime(date, startMin), wallClockTime(date, startMin),
@@ -493,6 +551,14 @@ class TimelineDragController {
*/ */
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null) private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
/**
* Earlier drops still being written when another block was picked up. They
* keep their copy and their ghost until their own token settles, rather than
* vanishing and flashing back at the old slot (#494).
*/
var held: List<HeldDrop> by mutableStateOf(emptyList())
private set
/** How long a block drafted on an empty slot starts out, in minutes (#153). */ /** How long a block drafted on an empty slot starts out, in minutes (#153). */
var draftMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION var draftMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION
@@ -591,14 +657,8 @@ class TimelineDragController {
var settlingSelected: Boolean = false var settlingSelected: Boolean = false
private set private set
/** /** The token of the drop [settling] waits on. */
* The abandoned-move counter, read at the drop so the mark can't be a var settledToken: Long = 0L
* composition behind the tick it is compared against.
*/
var abandonedTicks: StateFlow<Int>? = null
/** What [abandonedTicks] stood at when the settling drop was let go. */
var settledAbandonedMark: Int = 0
private set private set
private var source: TimedBlock? = null private var source: TimedBlock? = null
@@ -629,6 +689,32 @@ class TimelineDragController {
/** The resize in progress, or null for a plain drag (#141). */ /** The resize in progress, or null for a plain drag (#141). */
private var resize: ResizeSession? = null private var resize: ResizeSession? = null
/** The strip drag in progress; over the grid it drives [drag] through a timed stand-in. */
var stripDrag: StripDrag? by mutableStateOf(null)
private set
/** A strip drop held at its new days until the strip redraws the event there. */
var stripSettling: StripDrag? by mutableStateOf(null)
private set
/** Whether the strip now draws [stripSettling] at its new days itself. */
var stripSeated: Boolean by mutableStateOf(false)
private set
/** Strip drops parked by a newer strip drop while their writes still run (#494). */
var heldStrips: List<HeldStrip> by mutableStateOf(emptyList())
private set
/** The token of the drop [stripSettling] waits on. */
var stripToken: Long = 0L
private set
/** The shown day the finger picked the strip bar up on. */
private var stripGrabIndex = 0
/** Whether a strip drag over the grid has cleared its top edge, and may now scroll up. */
private var stripScrollArmed = false
fun begin( fun begin(
block: TimedBlock, block: TimedBlock,
clipOffsetMin: Int, clipOffsetMin: Int,
@@ -645,7 +731,7 @@ class TimelineDragController {
dragDuration = (block.event.end - block.event.start).coerceAtLeast(Duration.ZERO) dragDuration = (block.event.end - block.event.start).coerceAtLeast(Duration.ZERO)
dragKept = zone.repeatedHourOffsetAt(block.event.start) dragKept = zone.repeatedHourOffsetAt(block.event.start)
this.titleLines = titleLines this.titleLines = titleLines
settling = null parkSettling()
isDragging = true isDragging = true
liftedInstanceId = block.event.instanceId liftedInstanceId = block.event.instanceId
grab = pointerInRoot - blockInRoot grab = pointerInRoot - blockInRoot
@@ -667,6 +753,86 @@ class TimelineDragController {
begin(block, clipOffsetMin = 0, zone, titleLines = 1, pointerInRoot = pointerInRoot, blockInRoot = blockInRoot) begin(block, clipOffsetMin = 0, zone, titleLines = 1, pointerInRoot = pointerInRoot, blockInRoot = blockInRoot)
} }
/**
* Lift [event] off the all-day strip. Over the time grid it is dragged as a
* block [draftMinutes] long, whose top sits a little above the finger.
*/
fun beginStrip(event: EventInstance, zone: TimeZone, pointerInRoot: Offset) {
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
if (geometry.columnWidthPx <= 0f || geometry.days.isEmpty()) return
deselect()
if (stripSettling?.event?.instanceId == event.instanceId) releaseStrip()
stripScrollArmed = false
val timed = event.copy(isAllDay = false, end = event.start + draftMinutes.minutes)
source = TimedBlock(timed, 0, draftMinutes, lane = 0, laneCount = 1)
clipOffsetMin = 0
dragZone = zone
dragDuration = draftMinutes.minutes
dragKept = null
titleLines = 1
parkSettling()
isDragging = true
liftedInstanceId = event.instanceId
grab = Offset(0f, geometry.hourPx * STRIP_GRAB_MINUTES / 60f)
pointer = pointerInRoot
pickupPointer = pointerInRoot
pointerMoved = false
originSlot = null
stripGrabIndex = dayIndexAt(pointerInRoot.x, grid.positionInRoot().x)
stripDrag = StripDrag(event)
}
/** The shown day under [x], clamped to the days on screen. */
private fun dayIndexAt(x: Float, originX: Float): Int =
column(((x - originX) / geometry.columnWidthPx).toInt().coerceIn(0, geometry.days.lastIndex))
/** End a strip drag, handing back where it landed — null when it went nowhere. */
fun finishStrip(): StripDrop? {
val held = stripDrag ?: return null
val landed = drag
val zone = dragZone
cancel()
if (held.overGrid && landed != null && zone != null && !landed.edgeOnly) {
val token = settle(landed)
val start = dropStartInstant(landed.event, landed.date, landed.startMin, clipOffsetMin = 0, zone)
return StripDrop.Timed(held.event, start, start + draftMinutes.minutes, token)
}
if (held.overGrid || held.dayShift == 0) return null
// One still settling keeps its place on the strip until its own write lands (#494).
stripSettling?.let { heldStrips = heldStrips + HeldStrip(it, stripToken, stripSeated) }
stripSettling = held
stripSeated = false
stripToken = nextMoveToken()
drops++
return StripDrop.Days(held.event, held.dayShift, stripToken)
}
/** Let the held strip drop go, once the strip shows the event at its new days or the move failed. */
fun releaseStrip() {
stripSettling = null
stripSeated = false
}
/** Note that the strip draws the settling drop at its new days. */
fun seatStrip() {
if (stripSettling != null) stripSeated = true
}
/** Note that the strip draws the parked strip drop [token] at its new days. */
fun seatHeldStrip(token: Long) {
if (heldStrips.any { it.token == token && !it.seated }) {
heldStrips = heldStrips.map { if (it.token == token) it.copy(seated = true) else it }
}
}
/** Let the parked strip drop [token] go. */
fun releaseHeldStrip(token: Long) {
heldStrips = heldStrips.filterNot { it.token == token }
}
/** Every strip drop still shown at its new days: the parked ones, then the latest. */
fun stripPreviews(): List<StripDrag> = heldStrips.map { it.drag } + listOfNotNull(stripDrag ?: stripSettling)
fun move(pointerInRoot: Offset) { fun move(pointerInRoot: Offset) {
pointer = pointerInRoot pointer = pointerInRoot
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
@@ -675,6 +841,7 @@ class TimelineDragController {
fun cancel() { fun cancel() {
resize = null resize = null
stripDrag = null
source = null source = null
clipOffsetMin = 0 clipOffsetMin = 0
dragZone = null dragZone = null
@@ -700,12 +867,36 @@ class TimelineDragController {
* an event that crosses midnight (#253). * an event that crosses midnight (#253).
*/ */
fun ghosts(block: TimedBlock): Boolean { fun ghosts(block: TimedBlock): Boolean {
if (held.any { !it.handedOver && it.ghosts(block) }) return true
if (liftedInstanceId == null) return false if (liftedInstanceId == null) return false
if (block.event.instanceId == liftedInstanceId) return true if (block.event.instanceId == liftedInstanceId) return true
val (eventId, start) = settledOrigin ?: return false val (eventId, start) = settledOrigin ?: return false
return block.event.eventId == eventId && block.event.start == start return block.event.eventId == eventId && block.event.start == start
} }
/** Move a still-settling drop to [held] before a new pickup takes the slot. */
private fun parkSettling() {
val landed = settling
val origin = settledOrigin
if (landed != null && origin != null && !landed.event.isDraft) {
held = held + HeldDrop(landed, settledToken, landed.event.instanceId, origin, settledEnd, settledOnGrid)
}
settling = null
settledOnGrid = false
settledOrigin = null
settledEnd = null
}
/** Stop ghosting the held drop [token]'s source while its copy fades. */
fun handOverHeld(token: Long) {
held = held.map { if (it.token == token) it.copy(handedOver = true) else it }
}
/** Drop the held copy [token] once it has faded. */
fun releaseHeld(token: Long) {
held = held.filterNot { it.token == token }
}
/** /**
* What a day column now holds, so a settled drop can tell when the grid has * 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 * caught up with it. Matched on the landing slot plus either the event row
@@ -713,16 +904,23 @@ class TimelineDragController {
* blank title matches nothing: every untitled event has one (#253). * blank title matches nothing: every untitled event has one (#253).
*/ */
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) { fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
if (held.any { !it.seated && drawsDrop(it.copy, it.end, date, blocks) }) {
held = held.map { if (!it.seated && drawsDrop(it.copy, it.end, date, blocks)) it.copy(seated = true) else it }
}
val landed = settling ?: return val landed = settling ?: return
if (settledOnGrid) return if (settledOnGrid) return
settledOnGrid = drawsDrop(landed, settledEnd, date, blocks)
}
/** Whether [blocks], [date]'s column, draws the drop [landed] where it landed. */
private fun drawsDrop(landed: TimelineDrag, end: Instant?, date: LocalDate, blocks: List<TimedBlock>): Boolean {
// Where this day would draw it, which for a drop clipped at midnight is // Where this day would draw it, which for a drop clipped at midnight is
// the day's own start rather than the edge the finger held (#253) — and // the day's own start rather than the edge the finger held (#253) — and
// which for a tail dragged clear of its day is the day before it. // which for a tail dragged clear of its day is the day before it.
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt() val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset) val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
.firstOrNull() ?: return .firstOrNull() ?: return false
val end = settledEnd return blocks.any { block ->
settledOnGrid = blocks.any { block ->
block.startMin == slice.startMin && block.startMin == slice.startMin &&
// A resize may keep its start, so its end has to have landed too. // A resize may keep its start, so its end has to have landed too.
(end == null || block.event.end == end) && (end == null || block.event.end == end) &&
@@ -749,23 +947,31 @@ class TimelineDragController {
if (landed == null || landed.slot == origin) return null if (landed == null || landed.slot == origin) return null
// A draft dropped on a day off screen would vanish, so it stays where it was (#153). // A draft dropped on a day off screen would vanish, so it stays where it was (#153).
if (landed.edgeOnly && landed.event.isDraft) return null if (landed.edgeOnly && landed.event.isDraft) return null
settle(landed) val token = settle(landed)
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset) return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset, token)
} }
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */ /**
private fun settle(landed: TimelineDrag, end: Instant? = null) { * Keep [landed]'s copy on the target as [settling] until [release]; [end] is a
* resize's. Returns the drop's fresh token.
*/
private fun settle(landed: TimelineDrag, end: Instant? = null): Long {
// Landed on midnight to midnight, the event moves to the all-day strip (#296).
val leavesGrid = !landed.event.isDraft && landed.eventSpanMin > 0 &&
landed.eventStartMin.mod(MINUTES_PER_DAY) == 0 &&
landed.eventSpanMin % MINUTES_PER_DAY == 0
settlingSelected = selection?.event?.instanceId == landed.event.instanceId settlingSelected = selection?.event?.instanceId == landed.event.instanceId
// An edge piece is a hint for the finger, not a preview of the landing // An edge piece is a hint for the finger, not a preview of the landing
// slot: the event belongs to a day this timeline has no column for, so // slot: the event belongs to a day this timeline has no column for, so
// [noteGrid] can never seat it and it would sit at the top of today for // [noteGrid] can never seat it and it would sit at the top of today for
// the whole settle timeout, long after the event moved off-screen. // the whole settle timeout, long after the event moved off-screen.
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed settling = if (landed.edgeOnly || leavesGrid) landed.copy(pieces = emptyList()) else landed
liftedInstanceId = landed.event.instanceId liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start settledOrigin = landed.event.eventId to landed.event.start
settledAbandonedMark = abandonedTicks?.value ?: 0 settledToken = nextMoveToken()
settledEnd = end settledEnd = end
drops++ drops++
return settledToken
} }
/** /**
@@ -785,7 +991,7 @@ class TimelineDragController {
resize = session resize = session
clipOffsetMin = 0 clipOffsetMin = 0
this.titleLines = titleLines this.titleLines = titleLines
settling = null parkSettling()
isDragging = true isDragging = true
pointerMoved = true pointerMoved = true
liftedInstanceId = block.event.instanceId liftedInstanceId = block.event.instanceId
@@ -795,7 +1001,9 @@ class TimelineDragController {
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY)) val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
session.column = edgeIndex.takeIf { it >= 0 } session.column = edgeIndex.takeIf { it >= 0 }
?.let { column(it) } ?.let { column(it) }
session.grabMin = (pointerResizeMinutes(session) ?: 0f) - // The top handle sits on the drawn top, which a short block near midnight lifts (#483).
val lift = if (edge == ResizeEdge.Top) block.startMin - block.renderedSpan().topMin else 0f
session.grabMin = (pointerResizeMinutes(session) ?: 0f) + lift -
if (edge == ResizeEdge.Top) session.startMin else session.endMin if (edge == ResizeEdge.Top) session.startMin else session.endMin
originSlot = null originSlot = null
recompute() recompute()
@@ -814,10 +1022,10 @@ class TimelineDragController {
val event = source?.event ?: return null val event = source?.event ?: return null
val edge = resize?.edge val edge = resize?.edge
if (edge != null) { if (edge != null) {
val drop = resizedDrop(event, edge, date, startMin, spanMin, zone) val drop = resizedSpan(event, edge, date, startMin, spanMin, zone)
return event.copy(start = drop.start, end = drop.end) return event.copy(start = drop.start, end = drop.end)
} }
val start = TimelineDrop(event, date, startMin, clipOffsetMin).startInstant(zone) val start = dropStartInstant(event, date, startMin, clipOffsetMin, zone)
return event.copy(start = start, end = start + dragDuration) return event.copy(start = start, end = start + dragDuration)
} }
@@ -831,9 +1039,8 @@ class TimelineDragController {
val edge = resize?.edge val edge = resize?.edge
cancel() cancel()
if (landed == null || landed.slot == origin) return null if (landed == null || landed.slot == origin) return null
val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone) val span = resizedSpan(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
settle(landed, end = drop.end) return TimelineResizeDrop(landed.event, span.start, span.end, settle(landed, end = span.end))
return drop
} }
/** /**
@@ -855,7 +1062,9 @@ class TimelineDragController {
session.column = held session.column = held
val dayOffset = (days[column(held)].toEpochDays() - session.day.toEpochDays()).toInt() val dayOffset = (days[column(held)].toEpochDays() - session.day.toEpochDays()).toInt()
session.edgeDay = dayOffset session.edgeDay = dayOffset
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f // Held to its column's midnight: the clearance under it is not the next day.
val minutes = ((pointer.y - origin.y) / hourPx * 60f).coerceAtMost(MINUTES_PER_DAY.toFloat())
return dayOffset * MINUTES_PER_DAY + minutes
} }
private fun recomputeResize(session: ResizeSession) { private fun recomputeResize(session: ResizeSession) {
@@ -928,6 +1137,29 @@ class TimelineDragController {
recomputeResize(it) recomputeResize(it)
return return
} }
stripDrag?.let {
recomputeStrip(it)
return
}
recomputeDrag()
}
/** Over the strip the bar moves by whole days; past its bottom edge it is a timed block. */
private fun recomputeStrip(held: StripDrag) {
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return
if (geometry.columnWidthPx <= 0f || geometry.days.isEmpty()) return
if (pointer.y >= viewport.boundsInRoot().top) {
stripDrag = held.copy(overGrid = true)
recomputeDrag()
} else {
drag = null
val shift = dayIndexAt(pointer.x, grid.positionInRoot().x) - stripGrabIndex
stripDrag = held.copy(dayShift = shift, overGrid = false)
}
}
private fun recomputeDrag() {
val block = source ?: return val block = source ?: return
val grid = geometry.grid?.takeIf { it.isAttached } ?: return val grid = geometry.grid?.takeIf { it.isAttached } ?: return
val hourPx = geometry.hourPx val hourPx = geometry.hourPx
@@ -1013,11 +1245,18 @@ class TimelineDragController {
} }
private fun edgeStep(): Float { private fun edgeStep(): Float {
val strip = stripDrag
if (strip?.overGrid == false) return 0f
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return 0f val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return 0f
val edge = geometry.edgePx val edge = geometry.edgePx
if (edge <= 0f) return 0f if (edge <= 0f) return 0f
val bounds = viewport.boundsInRoot() val bounds = viewport.boundsInRoot()
val fromTop = pointer.y - bounds.top val fromTop = pointer.y - bounds.top
// Coming down out of the strip the finger starts inside the top edge.
if (strip != null && !stripScrollArmed) {
if (fromTop < edge) return 0f
stripScrollArmed = true
}
val fromBottom = bounds.bottom - pointer.y val fromBottom = bounds.bottom - pointer.y
return when { return when {
fromTop < edge -> -(edge - fromTop) / edge * geometry.stepPx fromTop < edge -> -(edge - fromTop) / edge * geometry.stepPx
@@ -1055,7 +1294,6 @@ fun rememberTimelineDragController(): TimelineDragController {
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() } controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() } controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
controller.slopPx = LocalViewConfiguration.current.touchSlop controller.slopPx = LocalViewConfiguration.current.touchSlop
controller.abandonedTicks = LocalEventMove.current?.abandoned
return controller return controller
} }
@@ -1077,10 +1315,6 @@ fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int {
fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean = fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean =
event.start < day.atStartOfDayIn(zone) event.start < day.atStartOfDayIn(zone)
/** True when the event runs past [day]'s midnight, so this block is cut there. */
fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
event.end > day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
/** /**
* The instant a drop asks for — the *event's* new start, so the tail of an * The instant a drop asks for — the *event's* new start, so the tail of an
* overnight event drops back onto the day its own start belongs to. Read in the * overnight event drops back onto the day its own start belongs to. Read in the
@@ -1089,7 +1323,16 @@ fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
* visibly settles there. * visibly settles there.
*/ */
fun TimelineDrop.startInstant(zone: TimeZone): Instant = fun TimelineDrop.startInstant(zone: TimeZone): Instant =
wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start)) dropStartInstant(event, date, startMin, clipOffsetMin, zone)
/** Where [event] starts when its block's top edge lands [startMin] past [date]'s midnight — see [TimelineDrop]. */
internal fun dropStartInstant(
event: EventInstance,
date: LocalDate,
startMin: Int,
clipOffsetMin: Int,
zone: TimeZone,
): Instant = wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start))
/** /**
* The wall-clock minutes an event lasting [duration] covers when it starts * The wall-clock minutes an event lasting [duration] covers when it starts
@@ -1161,6 +1404,7 @@ private const val SETTLE_TIMEOUT_MILLIS = 900L
*/ */
const val SETTLE_FADE_MILLIS: Int = 250 const val SETTLE_FADE_MILLIS: Int = 250
/** /**
* The floating block, drawn over the whole calendar so it is free of the day * 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. * column's clip and of the scroll viewport's rounded corners.
@@ -1168,16 +1412,14 @@ const val SETTLE_FADE_MILLIS: Int = 250
@Composable @Composable
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) { fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
var origin by remember { mutableStateOf(Offset.Zero) } var origin by remember { mutableStateOf(Offset.Zero) }
val density = LocalDensity.current
val dark = LocalFloretDarkTheme.current
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
val moveInFlight = moveInFlight() val drops = dropStates()
val moveInFlight = controller.settledToken in drops.pending
val stripInFlight = controller.stripToken in drops.pending
// Both live here rather than beside the controller: they read [drag], which // 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. // changes every frame, and this composable is the one that is meant to.
DragSnapHaptics(controller.drag?.slot, controller.drops) DragSnapHaptics(controller.drag?.slot ?: controller.stripDrag?.dayShift, controller.drops)
// Only once the finger has moved: a long press held near an edge must not // Only once the finger has moved: a long press held near an edge must not
// scroll the drop target out from under a finger that only means to select. // scroll the drop target out from under a finger that only means to select.
LaunchedEffect(controller.isDragging && controller.pointerMoved) { LaunchedEffect(controller.isDragging && controller.pointerMoved) {
@@ -1187,12 +1429,11 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
// recurring drop's scope dialog is up — and then until the grid draws the // recurring drop's scope dialog is up — and then until the grid draws the
// drop itself. // drop itself.
var handingOver by remember(controller.settling) { mutableStateOf(false) } var handingOver by remember(controller.settling) { mutableStateOf(false) }
val abandoned = abandonedMoves() val abandoned = controller.settledToken in drops.abandoned
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid, abandoned) { LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid, abandoned) {
if (controller.settling == null || moveInFlight) return@LaunchedEffect if (controller.settling == null || moveInFlight) return@LaunchedEffect
// Past the mark this drop was let go on, it was abandoned: nothing will // An abandoned drop: nothing will land and there is nothing to wait for.
// land and there is nothing to wait for. if (!abandoned) {
if (abandoned == controller.settledAbandonedMark) {
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS) delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
} }
handingOver = true handingOver = true
@@ -1201,6 +1442,16 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
controller.release() controller.release()
} }
// The same hold for a bar moved along the all-day strip.
val stripAbandoned = controller.stripToken in drops.abandoned
LaunchedEffect(controller.stripSettling, stripInFlight, controller.stripSeated, stripAbandoned) {
if (controller.stripSettling == null || stripInFlight) return@LaunchedEffect
if (!stripAbandoned) {
delay(if (controller.stripSeated) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
}
controller.releaseStrip()
}
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@@ -1209,6 +1460,12 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
.clearAndSetSemantics { } .clearAndSetSemantics { }
.onGloballyPositioned { origin = it.positionInRoot() }, .onGloballyPositioned { origin = it.positionInRoot() },
) { ) {
controller.held.forEach { parked ->
key(parked.token) { HeldTimelineCopy(controller, parked, origin) }
}
controller.heldStrips.forEach { parked ->
key(parked.token) { HeldStripSettle(controller, parked) }
}
val drag = controller.drag ?: controller.settling ?: return@Box val drag = controller.drag ?: controller.settling ?: return@Box
// Landed: the copy sinks back to the grid's own plane while the write runs. // Landed: the copy sinks back to the grid's own plane while the write runs.
val landed = controller.drag == null val landed = controller.drag == null
@@ -1222,6 +1479,58 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
animationSpec = tween(SETTLE_FADE_MILLIS), animationSpec = tween(SETTLE_FADE_MILLIS),
label = "drag-handover", label = "drag-handover",
) )
TimelineCopy(drag, controller.geometry, origin, lift, copyAlpha)
}
}
/**
* [TimelineDragController.ghosts] for [block], derived so the block recomposes
* only when its own answer changes rather than on every lift, park and release.
*/
@Composable
fun TimelineDragController.ghostsDerived(block: TimedBlock): Boolean {
val ghosted by remember(this, block) { derivedStateOf { ghosts(block) } }
return ghosted
}
/** A drop parked by a newer pickup: held at its landing slot until its own write settles. */
@Composable
private fun HeldTimelineCopy(controller: TimelineDragController, parked: HeldDrop, origin: Offset) {
val alpha = rememberHeldSettle(
token = parked.token,
seated = parked.seated,
timeoutMillis = SETTLE_TIMEOUT_MILLIS,
onHandOver = { controller.handOverHeld(parked.token) },
onRelease = { controller.releaseHeld(parked.token) },
)
TimelineCopy(parked.copy, controller.geometry, origin, lift = 0f, copyAlpha = alpha)
}
/** A strip drop parked by a newer one: shown at its new days until its own write settles. */
@Composable
private fun HeldStripSettle(controller: TimelineDragController, parked: HeldStrip) {
rememberHeldSettle(
token = parked.token,
seated = parked.seated,
timeoutMillis = SETTLE_TIMEOUT_MILLIS,
onHandOver = {},
onRelease = { controller.releaseHeldStrip(parked.token) },
)
}
/** [drag]'s pieces drawn over the calendar, clipped to the timeline's viewport. */
@Composable
private fun TimelineCopy(
drag: TimelineDrag,
geometry: TimelineGeometry,
origin: Offset,
lift: Float,
copyAlpha: Float,
) {
val density = LocalDensity.current
val dark = LocalFloretDarkTheme.current
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val paint = eventPaint(drag.event, dark) val paint = eventPaint(drag.event, dark)
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) } val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
// Read off the event rather than the held block, so grabbing either half // Read off the event rather than the held block, so grabbing either half
@@ -1237,7 +1546,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
// so no column clip or rounded corner cuts it, but it still belongs to // so no column clip or rounded corner cuts it, but it still belongs to
// the timeline: a piece a whole day tall reaches far past both ends of // the timeline: a piece a whole day tall reaches far past both ends of
// the viewport, and unclipped it paints over the headers above (#267). // the viewport, and unclipped it paints over the headers above (#267).
val port = controller.geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot() val port = geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
// How much of each piece the viewport actually shows. A multi-day event // How much of each piece the viewport actually shows. A multi-day event
// has a piece per day, and the widest one is a full 24 hours — taller // has a piece per day, and the widest one is a full 24 hours — taller
// than the screen, its top at a midnight scrolled out of sight. Picking // than the screen, its top at a midnight scrolled out of sight. Picking
@@ -1289,7 +1598,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
) )
} }
} }
}
} }
/** One piece of a dragged block, floating over the calendar. */ /** One piece of a dragged block, floating over the calendar. */
@@ -43,14 +43,21 @@ data class TimelineHours(
/** /**
* The scroll offset that puts [startHour] at the top of a timeline laid out * The scroll offset that puts [startHour] at the top of a timeline laid out
* with [maxValue] scroll range over a [viewportPx] viewport, or null while that * with [maxValue] scroll range over a [viewportPx] viewport, [clearancePx] of
* which is the empty inset under midnight, or null while that
* layout is not yet at [hourPx] per hour — the scale changes a frame before the * layout is not yet at [hourPx] per hour — the scale changes a frame before the
* content grows to match it, and scrolling against the old layout would land on * content grows to match it, and scrolling against the old layout would land on
* the wrong hour. * the wrong hour.
*/ */
internal fun startHourScroll(maxValue: Int, viewportPx: Int, startHour: Int, hourPx: Float): Int? { internal fun startHourScroll(
maxValue: Int,
viewportPx: Int,
startHour: Int,
hourPx: Float,
clearancePx: Int = 0,
): Int? {
if (viewportPx <= 0 || maxValue < 0 || maxValue == Int.MAX_VALUE) return null if (viewportPx <= 0 || maxValue < 0 || maxValue == Int.MAX_VALUE) return null
val laidOutHourPx = (maxValue + viewportPx) / 24f val laidOutHourPx = (maxValue + viewportPx - clearancePx) / 24f
if (abs(laidOutHourPx - hourPx) > 1f) return null if (abs(laidOutHourPx - hourPx) > 1f) return null
return (startHour * laidOutHourPx).roundToInt().coerceIn(0, maxValue) return (startHour * laidOutHourPx).roundToInt().coerceIn(0, maxValue)
} }
@@ -4,6 +4,8 @@ import androidx.annotation.StringRes
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
/** /**
* How tall one hour is drawn in the week and day timelines (#56). * How tall one hour is drawn in the week and day timelines (#56).
@@ -59,8 +61,9 @@ sealed interface TimelineScale {
} }
/** /**
* Hour height for this scale. [viewportHeight] is the visible height of the * Hour height for this scale. [viewportHeight] is the height the day is fitted
* scrolling timeline and is only consulted by [TimelineScale.FitDay]. * to, the scrolling timeline's [dayFitHeight], and is not consulted by the
* fixed presets.
* *
* The fit-day result is clamped: below [FIT_DAY_MIN] the 24 gutter labels stop * 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 * being legible, and above [FIT_DAY_MAX] a short landscape day would stretch its
@@ -85,6 +88,13 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
.coerceAtLeast(fillHourHeight(viewportHeight)) .coerceAtLeast(fillHourHeight(viewportHeight))
} }
/**
* The part of a [viewportHeight]-tall timeline the day is fitted to: the
* [FabClearance] under midnight, which lets the last hour scroll clear of the
* FAB (#483), is left out. Pass this, not the raw viewport, to [hourHeight].
*/
fun dayFitHeight(viewportHeight: Dp): Dp = (viewportHeight - FabClearance).coerceAtLeast(0.dp)
/** /**
* The minute of the day a tap [offsetY] px down a timeline column means, snapped * The minute of the day a tap [offsetY] px down a timeline column means, snapped
* to the hour it landed in. * to the hour it landed in.
@@ -105,6 +115,28 @@ fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
*/ */
const val MIN_EVENT_FRACTION = 26f / 60f const val MIN_EVENT_FRACTION = 26f / 60f
/** Where a timed block is drawn down its day, in minutes from midnight. */
data class RenderedSpan(val topMin: Float, val bottomMin: Float)
/**
* Where a block of [startMin]..[endMin] is drawn, at least [MIN_EVENT_FRACTION]
* of an hour tall. A short block near midnight rises until that floor ends at
* midnight instead of running off the day, unless it [continuesAfter] into the
* next one (#483).
*/
fun renderedSpan(startMin: Int, endMin: Int, continuesAfter: Boolean): RenderedSpan {
val floor = MIN_EVENT_FRACTION * 60f
val bottom = maxOf(endMin.toFloat(), startMin + floor)
return if (continuesAfter || bottom <= MINUTES_PER_DAY) {
RenderedSpan(startMin.toFloat(), bottom)
} else {
RenderedSpan(MINUTES_PER_DAY - floor, MINUTES_PER_DAY.toFloat())
}
}
/** This block's [renderedSpan]. */
fun TimedBlock.renderedSpan(): RenderedSpan = renderedSpan(startMin, endMin, continuesAfter)
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */ /** Smallest hour height [TimelineScale.FitDay] will resolve to. */
val FIT_DAY_MIN = 24.dp val FIT_DAY_MIN = 24.dp
@@ -32,16 +32,21 @@ val LocalTimelineScroll = staticCompositionLocalOf<TimelineScrollMemory?> { null
/** A timeline's scroll, and where it opens before it can scroll there. */ /** A timeline's scroll, and where it opens before it can scroll there. */
@Stable @Stable
class TimelineScroll(val state: ScrollState, noonPending: Boolean, private val openingHour: Int?) { class TimelineScroll(
val state: ScrollState,
noonPending: Boolean,
private val openingHour: Int?,
private val clearancePx: Float = 0f,
) {
/** Whether it is still to be put on noon, which it only can be once laid out. */ /** Whether it is still to be put on noon, which it only can be once laid out. */
var noonPending by mutableStateOf(noonPending) var noonPending by mutableStateOf(noonPending)
internal set internal set
/** The top of what it shows, or will once open, over [content] pixels in a [viewport] pixels high. */ /** The top of what it shows, or will once open, over [content] pixels of day in a [viewport] pixels high. */
fun top(viewport: Float, content: Float): Float = fun top(viewport: Float, content: Float): Float =
if (noonPending) { if (noonPending) {
(openingHour?.let { it * content / 24f } ?: (content - viewport) / 2f) (openingHour?.let { it * content / 24f } ?: (content - viewport) / 2f)
.coerceIn(0f, (content - viewport).coerceAtLeast(0f)) .coerceIn(0f, (content + clearancePx - viewport).coerceAtLeast(0f))
} else state.value.toFloat() } else state.value.toFloat()
} }
@@ -58,6 +63,7 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
val density = LocalDensity.current val density = LocalDensity.current
val zoom = LocalTimelineZoom.current val zoom = LocalTimelineZoom.current
val topPx = with(density) { timelineTop.toPx() } val topPx = with(density) { timelineTop.toPx() }
val clearancePx = with(density) { FabClearance.roundToPx() }
val remembered = memory?.midnightY val remembered = memory?.midnightY
val scrollState = rememberScrollState( val scrollState = rememberScrollState(
initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0, initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0,
@@ -67,6 +73,7 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
scrollState, scrollState,
noonPending = remembered == null, noonPending = remembered == null,
openingHour = zoom.hours.takeIf { it.enabled }?.startHour, openingHour = zoom.hours.takeIf { it.enabled }?.startHour,
clearancePx = clearancePx.toFloat(),
) )
} }
val currentTop by rememberUpdatedState(topPx) val currentTop by rememberUpdatedState(topPx)
@@ -80,20 +87,20 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
val startHour = zoom.open() val startHour = zoom.open()
if (startHour == null) { if (startHour == null) {
snapshotFlow { scrollState.maxValue }.first { it > 0 } snapshotFlow { scrollState.maxValue }.first { it > 0 }
scrollState.scrollTo(scrollState.maxValue / 2) scrollState.scrollTo(((scrollState.maxValue - clearancePx) / 2).coerceAtLeast(0))
} else { } else {
// Fitted to the scroll viewport itself: the layout resolves scales // Fitted to the scroll viewport itself: the layout resolves scales
// against the viewport plus the all-day strip, which would push the // against the viewport plus the all-day strip, which would push the
// end hour below the screen. // end hour below the screen.
val viewport = snapshotFlow { scrollState.viewportSize }.first { it > 0 } val viewport = snapshotFlow { scrollState.viewportSize }.first { it > 0 }
val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() }) val hourHeight = zoom.scale.hourHeight(dayFitHeight(with(density) { viewport.toDp() }))
zoom.holdFit(hourHeight) zoom.holdFit(hourHeight)
val hourPx = with(density) { hourHeight.toPx() } val hourPx = with(density) { hourHeight.toPx() }
val target = withTimeoutOrNull(FIT_LAYOUT_TIMEOUT_MILLIS) { val target = withTimeoutOrNull(FIT_LAYOUT_TIMEOUT_MILLIS) {
snapshotFlow { snapshotFlow {
startHourScroll(scrollState.maxValue, scrollState.viewportSize, startHour, hourPx) startHourScroll(scrollState.maxValue, scrollState.viewportSize, startHour, hourPx, clearancePx)
}.filterNotNull().first() }.filterNotNull().first()
} ?: (startHour * (scrollState.maxValue + scrollState.viewportSize) / 24) } ?: (startHour * (scrollState.maxValue + scrollState.viewportSize - clearancePx) / 24)
.coerceIn(0, scrollState.maxValue.coerceAtLeast(0)) .coerceIn(0, scrollState.maxValue.coerceAtLeast(0))
scrollState.scrollTo(target) scrollState.scrollTo(target)
} }
@@ -0,0 +1,21 @@
package de.jeanlucmakiola.calendula.ui.common
import android.content.res.Resources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import de.jeanlucmakiola.floret.time.rowWeekNumber
import kotlinx.datetime.LocalDate
import kotlinx.datetime.toJavaLocalDate
import java.time.temporal.WeekFields
/**
* The week number of the grid row starting on [rowStart], numbered by the
* device region's rules (#493). The region, not the app language: an English
* UI in the Netherlands still numbers weeks the ISO way. Shared by the Month
* gutter and the Week badge so the two always agree.
*/
@Composable
internal fun rememberRowWeekNumber(rowStart: LocalDate): Int {
val fields = remember { WeekFields.of(Resources.getSystem().configuration.locales[0]) }
return remember(rowStart, fields) { rowStart.toJavaLocalDate().rowWeekNumber(fields) }
}
@@ -37,6 +37,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import de.jeanlucmakiola.calendula.ui.common.orUnnamed import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.IconInfoCard import de.jeanlucmakiola.floret.components.IconInfoCard
import de.jeanlucmakiola.floret.components.SegmentedChoiceCard
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import de.jeanlucmakiola.calendula.ui.common.sendEventShare import de.jeanlucmakiola.calendula.ui.common.sendEventShare
@@ -119,6 +120,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventColorOption
@@ -892,6 +894,15 @@ private fun EventEditContent(
AvailabilityCard(availability = form.availability, onSelect = viewModel::setAvailability) AvailabilityCard(availability = form.availability, onSelect = viewModel::setAvailability)
} }
OptionalFormSection(visible = EventFormField.Status in state.visibleFields) {
Spacer(Modifier.height(gap))
StatusCard(
status = form.status,
offerCancelled = state.offerCancelled,
onSelect = viewModel::setStatus,
)
}
OptionalFormSection(visible = EventFormField.Visibility in state.visibleFields) { OptionalFormSection(visible = EventFormField.Visibility in state.visibleFields) {
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
IconInfoCard( IconInfoCard(
@@ -1314,33 +1325,33 @@ private fun RecurrenceCard(state: EventEditUiState, locale: Locale, onClick: (()
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun AvailabilityCard(availability: Availability, onSelect: (Availability) -> Unit) { private fun AvailabilityCard(availability: Availability, onSelect: (Availability) -> Unit) {
IconInfoCard( SegmentedChoiceCard(
icon = Icons.Default.EventAvailable, icon = Icons.Default.EventAvailable,
iconContentDescription = null, title = stringResource(R.string.event_edit_availability),
header = { options = listOf(
Text( Availability.Busy to stringResource(R.string.event_availability_busy),
text = stringResource(R.string.event_edit_availability), Availability.Free to stringResource(R.string.event_availability_free),
style = MaterialTheme.typography.titleMedium, ),
) selected = availability,
}, onSelect = onSelect,
) { )
Spacer(Modifier.height(8.dp)) }
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
SegmentedButton( @Composable
selected = availability == Availability.Busy, private fun StatusCard(status: EventStatus, offerCancelled: Boolean, onSelect: (EventStatus) -> Unit) {
onClick = { onSelect(Availability.Busy) }, SegmentedChoiceCard(
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), icon = eventFormFieldIcon(EventFormField.Status),
) { Text(stringResource(R.string.event_availability_busy)) } title = stringResource(R.string.event_edit_status),
SegmentedButton( options = listOfNotNull(
selected = availability == Availability.Free, EventStatus.Confirmed to stringResource(R.string.event_status_confirmed),
onClick = { onSelect(Availability.Free) }, EventStatus.Tentative to stringResource(R.string.event_status_tentative),
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), (EventStatus.Cancelled to stringResource(R.string.event_status_cancelled)).takeIf { offerCancelled },
) { Text(stringResource(R.string.event_availability_free)) } ),
} selected = status,
} onSelect = onSelect,
)
} }
/** /**
@@ -48,6 +48,12 @@ data class EventEditUiState(
* then drops "only this event" (an exception row can't carry a rule). * then drops "only this event" (an exception row can't carry a rule).
*/ */
val recurrenceChanged: Boolean = false, val recurrenceChanged: Boolean = false,
/**
* Whether the Status field offers Cancelled: only for an event that already
* is. Google reads a cancelled event as deleted, so picking it is no safe
* way to strike an event through (#388).
*/
val offerCancelled: Boolean = false,
/** /**
* The event-colour palette the resolved target calendar publishes; empty * The event-colour palette the resolved target calendar publishes; empty
* when it exposes none. Non-empty → the colour picker offers these swatches * when it exposes none. Non-empty → the colour picker offers these swatches
@@ -14,8 +14,10 @@ import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
import de.jeanlucmakiola.calendula.domain.canSaveApartFromSeries
import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EditSnapshot import de.jeanlucmakiola.calendula.domain.EditSnapshot
import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventAttendee
@@ -335,6 +337,7 @@ class EventEditViewModel @Inject constructor(
// the scope dialog drops "only this event" after a rule change. // the scope dialog drops "only this event" after a rule change.
recurrenceChanged = local.editTarget != null && recurrenceChanged = local.editTarget != null &&
resolved.rrule != local.editTarget.original.rrule, resolved.rrule != local.editTarget.original.rrule,
offerCancelled = local.editTarget?.original?.status == EventStatus.Cancelled,
colorPalette = palette, colorPalette = palette,
allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported, allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported,
) )
@@ -671,6 +674,7 @@ class EventEditViewModel @Inject constructor(
applyDefaultDuration(id) applyDefaultDuration(id)
} }
fun setAvailability(value: Availability) = edit { it.copy(availability = value) } fun setAvailability(value: Availability) = edit { it.copy(availability = value) }
fun setStatus(value: EventStatus) = edit { it.copy(status = value) }
fun setAccessLevel(value: AccessLevel) = edit { it.copy(accessLevel = value) } fun setAccessLevel(value: AccessLevel) = edit { it.copy(accessLevel = value) }
/** Pick a palette colour (round-trips via its key); [argb] is its swatch. */ /** Pick a palette colour (round-trips via its key); [argb] is its swatch. */
@@ -766,7 +770,10 @@ class EventEditViewModel @Inject constructor(
// Managed events are a yearly series whose editable fields (reminders, // Managed events are a yearly series whose editable fields (reminders,
// notes, location) live on the series row — never offer the scope dialog, // notes, location) live on the series row — never offer the scope dialog,
// which would split the series into an exception the sync then reverts. // which would split the series into an exception the sync then reverts.
if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar) { // A cancelled one only saves as the whole series (#388).
if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar &&
form.canSaveApartFromSeries()
) {
_saveState.value = SaveUiState.AwaitingScope _saveState.value = SaveUiState.AwaitingScope
return return
} }
@@ -2,17 +2,28 @@ package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
import de.jeanlucmakiola.calendula.ui.common.rememberWidestTimeSample
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import java.util.Locale import java.util.Locale
/** /**
* The start time a chip shows before its title (#219), or null when it has none * The start time a chip shows before its title (#219), or null when it has none
* to show: an all-day chip never carries a time, and neither does a bar carried * to show: a chip filling whole days never carries a time (#296), and neither
* in from an earlier week, whose start is not in this segment. * does a bar carried in from an earlier week, whose start is not in this segment.
*/ */
internal fun monthChipTime( internal fun monthChipTime(
event: EventInstance, event: EventInstance,
@@ -21,11 +32,75 @@ internal fun monthChipTime(
is24Hour: Boolean, is24Hour: Boolean,
locale: Locale, locale: Locale,
): String? { ): String? {
if (event.isAllDay || continuesLeft) return null if (event.coversWholeDays(zone) || continuesLeft) return null
return formatTimeOfDay(event.start, zone, is24Hour, locale) return formatTimeOfDay(event.start, zone, is24Hour, locale)
} }
/** The narrowest chip that may show a time — the text it needs, plus its chrome. */ /** How many characters of a longer title a chip must keep beside its start time (#476). */
internal const val MONTH_TITLE_MIN_CHARS = 12
/**
* The part of [title] that has to stay readable beside a start time: all of it,
* or its first [MONTH_TITLE_MIN_CHARS] characters, counted in code points so an
* emoji is never cut in half.
*/
internal fun monthTitleToKeep(title: String): String {
if (title.codePointCount(0, title.length) <= MONTH_TITLE_MIN_CHARS) return title
return title.substring(0, title.offsetByCodePoints(0, MONTH_TITLE_MIN_CHARS))
}
/**
* Whether a chip shows its start time (#219, #476): only with the setting
* [enabled], and only when [availableWidth] (the chip less its chrome) holds
* [labelWidth], the widest time followed by [monthTitleToKeep] of the title.
*/
internal fun monthChipShowsTime(
enabled: Boolean,
availableWidth: Float,
labelWidth: Float,
): Boolean = enabled && labelWidth <= availableWidth
/**
* Prices month chips' time labels for [monthChipShowsTime] in the chips' own
* text style. One per grid; each title is measured once for its lifetime.
*/
@Stable
internal class MonthChipTimeFit(
private val measurer: TextMeasurer,
private val style: TextStyle,
private val density: Density,
private val timeSample: String,
) {
private val chromePx = with(density) { MONTH_CHIP_CHROME.toPx() }
private val labelWidths = HashMap<String, Int>()
fun showsTime(chipWidth: Dp, title: String): Boolean {
if (labelWidths.size >= MAX_CACHED_TITLES) labelWidths.clear()
val labelWidth = labelWidths.getOrPut(title) {
val label = inlineTimeLabel(timeSample, monthTitleToKeep(title), Color.Unspecified)
measurer.measure(label, style, softWrap = false, maxLines = 1).size.width
}
return monthChipShowsTime(
enabled = true,
availableWidth = with(density) { chipWidth.toPx() } - chromePx,
labelWidth = labelWidth.toFloat(),
)
}
private companion object {
const val MAX_CACHED_TITLES = 1000
}
}
/** The grid's [MonthChipTimeFit], or null when start times are switched off (#476). */
@Composable @Composable
internal fun rememberMonthTimeChipWidth(): Dp = internal fun rememberMonthChipTimeFit(showStartTimes: Boolean): MonthChipTimeFit? {
rememberInlineTimeWidth(MaterialTheme.typography.labelSmall) + MONTH_CHIP_CHROME if (!showStartTimes) return null
val measurer = rememberTextMeasurer(cacheSize = 0)
val style = MaterialTheme.typography.labelSmall.trimmedLines()
val density = LocalDensity.current
val timeSample = rememberWidestTimeSample()
return remember(measurer, style, density, timeSample) {
MonthChipTimeFit(measurer, style, density, timeSample)
}
}
@@ -14,8 +14,7 @@ import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.nextMoveToken
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlin.math.abs import kotlin.math.abs
@@ -107,11 +106,22 @@ data class MonthChipDrag(
val time: String? = null, val time: String? = null,
) )
/** A drop parked by a newer pickup while its write still runs — see [MonthDragController.held]. */
data class HeldChip(
val copy: MonthChipDrag,
val token: Long,
val handedOver: Boolean = false,
/** Where the grid now draws the drop, in root coordinates, once a row seats it. */
val seat: Offset? = null,
)
/** Where a finished chip drag asks its event to go, as a whole-day shift. */ /** Where a finished chip drag asks its event to go, as a whole-day shift. */
data class MonthChipDrop( data class MonthChipDrop(
val event: EventInstance, val event: EventInstance,
val grabDate: LocalDate, val grabDate: LocalDate,
val targetDate: LocalDate, val targetDate: LocalDate,
/** The drop's identity, which its held copy waits on — see [MonthDragController.settledToken]. */
val token: Long,
) )
/** /**
@@ -167,19 +177,22 @@ class MonthDragController {
private set private set
/** /**
* The last drop this controller made, kept past [release] for [beginUndo] — * This controller's recent drops by token, kept past [release] for
* the confirmation chip carries Undo long after the copy is gone. * [beginUndo] — the confirmation chip carries Undo long after the copy is
* gone, and it may be any of them the chip offers to undo.
*/ */
private var undoable: MonthChipDrag? = null private val undoable = LinkedHashMap<Long, MonthChipDrag>()
/** /**
* The abandoned-move counter, read at the drop so the mark can't be a * Earlier drops still being written when another chip was picked up. They
* composition behind the tick it is compared against. * keep their copy and their ghost until their own token settles, rather than
* vanishing and flashing back on the old day (#494).
*/ */
var abandonedTicks: StateFlow<Int>? = null var held: List<HeldChip> by mutableStateOf(emptyList())
private set
/** What [abandonedTicks] stood at when the settling chip was let go. */ /** The token of the drop (or undo) [settling] is waiting on. */
var settledAbandonedMark: Int = 0 var settledToken: Long = 0L
private set private set
private var event: EventInstance? = null private var event: EventInstance? = null
@@ -208,7 +221,7 @@ class MonthDragController {
this.event = event this.event = event
this.grabDate = grabDate this.grabDate = grabDate
this.time = time this.time = time
settling = null parkSettling()
isDragging = true isDragging = true
liftedInstanceId = event.instanceId liftedInstanceId = event.instanceId
grab = pointerInRoot - chipInRoot grab = pointerInRoot - chipInRoot
@@ -234,6 +247,39 @@ class MonthDragController {
settledGhost = false settledGhost = false
} }
/**
* Whether [event]'s chip belongs to a held drop and must stay a ghost: its
* source, or once a row seats it, the chip the grid draws on its target day.
*/
fun isHeldChip(event: EventInstance, days: List<LocalDate>?): Boolean = held.any { parked ->
!parked.handedOver && (
parked.copy.event.instanceId == event.instanceId ||
(
parked.seat != null && days != null && parked.copy.targetDate in days &&
isSameEvent(event, parked.copy.event)
)
)
}
/** Move a still-settling drop to [held] before a new pickup takes the slot. */
private fun parkSettling() {
val landed = settling
if (landed != null) held = held + HeldChip(landed, settledToken)
settling = null
settledInRoot = null
settledGhost = false
}
/** Stop ghosting the held drop [token]'s source while its copy fades. */
fun handOverHeld(token: Long) {
held = held.map { if (it.token == token) it.copy(handedOver = true) else it }
}
/** Drop the held copy [token] once it has faded. */
fun releaseHeld(token: Long) {
held = held.filterNot { it.token == token }
}
/** /**
* Whether [days] holds the chip the grid has drawn for the settled drop — * Whether [days] holds the chip the grid has drawn for the settled drop —
* the seat the copy is on its way to, which must ghost until it gets there. * the seat the copy is on its way to, which must ghost until it gets there.
@@ -250,6 +296,13 @@ class MonthDragController {
* nearest where the chip was let go is the one the finger was over. * nearest where the chip was let go is the one the finger was over.
*/ */
fun noteSettled(token: Any) { fun noteSettled(token: Any) {
val row = rows[token]
if (row != null && held.any { it.seat == null }) {
held = held.map { parked ->
val target = parked.copy.targetDate
if (parked.seat != null || target == null) parked else parked.copy(seat = row.seatOf(parked.copy.event, target))
}
}
val landed = settling ?: return val landed = settling ?: return
val target = landed.targetDate ?: return val target = landed.targetDate ?: return
val at = rows[token]?.seatOf(landed.event, target) ?: return val at = rows[token]?.seatOf(landed.event, target) ?: return
@@ -265,16 +318,16 @@ class MonthDragController {
* there is no drop of this controller's left to undo, or the grid doesn't * there is no drop of this controller's left to undo, or the grid doesn't
* seat the moved event where the chip would have to start from. * seat the moved event where the chip would have to start from.
*/ */
fun beginUndo(): Boolean { fun beginUndo(token: Long, dropToken: Long): Boolean {
val last = undoable ?: return false val last = undoable.remove(dropToken) ?: return false
undoable = null parkSettling()
val from = last.targetDate ?: return false val from = last.targetDate ?: return false
val at = rows.values.firstNotNullOfOrNull { it.seatOf(last.event, from) } ?: return false val at = rows.values.firstNotNullOfOrNull { it.seatOf(last.event, from) } ?: return false
settling = last.copy(grabDate = from, targetDate = last.grabDate, topLeftInRoot = at) settling = last.copy(grabDate = from, targetDate = last.grabDate, topLeftInRoot = at)
liftedInstanceId = last.event.instanceId liftedInstanceId = last.event.instanceId
settledGhost = true settledGhost = true
settledInRoot = null settledInRoot = null
settledAbandonedMark = abandonedTicks?.value ?: 0 settledToken = token
return true return true
} }
@@ -294,10 +347,11 @@ class MonthDragController {
) )
liftedInstanceId = landed.event.instanceId liftedInstanceId = landed.event.instanceId
settledGhost = true settledGhost = true
undoable = settling settledToken = nextMoveToken()
settledAbandonedMark = abandonedTicks?.value ?: 0 undoable[settledToken] = requireNotNull(settling)
if (undoable.size > UNDOABLE_KEPT) undoable.remove(undoable.keys.first())
drops++ drops++
return MonthChipDrop(landed.event, landed.grabDate, target) return MonthChipDrop(landed.event, landed.grabDate, target, settledToken)
} }
/** /**
@@ -355,9 +409,10 @@ class MonthDragController {
@Composable @Composable
fun rememberMonthDragController(): MonthDragController { fun rememberMonthDragController(): MonthDragController {
val controller = remember { MonthDragController() } return remember { MonthDragController() }
controller.abandonedTicks = LocalEventMove.current?.abandoned
return controller
} }
val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null } val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null }
/** How many recent drops a month controller keeps for an undo to carry back. */
private const val UNDOABLE_KEPT = 8
@@ -34,6 +34,10 @@ internal sealed interface MonthMorphKey {
data class DayNumber(val date: LocalDate) : MonthMorphKey data class DayNumber(val date: LocalDate) : MonthMorphKey
data class WeekNumber(val weekStart: LocalDate) : MonthMorphKey
data class CellRipple(val date: LocalDate) : MonthMorphKey
/** /**
* One event *on one day*. Keyed by date as well as instance because a * One event *on one day*. Keyed by date as well as instance because a
* multi-day event has a dot on every day it covers but only one bar, drawn * multi-day event has a dot on every day it covers but only one bar, drawn
@@ -111,8 +115,8 @@ internal fun morphInFlight(): Boolean {
* *
* Rendering in place puts everything back in one z-order and one clip, which is * Rendering in place puts everything back in one z-order and one clip, which is
* what makes the grid read as a single surface changing shape rather than a * what makes the grid read as a single surface changing shape rather than a
* stack of pieces sliding past each other. Nothing here needs to escape its * stack of pieces sliding past each other. Event marks are the exception: they
* ancestors: the cells, marks and markers all travel within the grid. * render in the overlay so the expanding day backgrounds cannot cover them.
*/ */
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
@@ -141,6 +145,7 @@ internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
@Composable @Composable
internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier { internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
val morph = LocalMonthMorph.current ?: return this val morph = LocalMonthMorph.current ?: return this
val isEvent = key is MonthMorphKey.Event
return with(morph.shared) { return with(morph.shared) {
this@morphBounds.sharedBounds( this@morphBounds.sharedBounds(
sharedContentState = rememberSharedContentState(key), sharedContentState = rememberSharedContentState(key),
@@ -148,7 +153,8 @@ internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
renderInOverlayDuringTransition = false, renderInOverlayDuringTransition = isEvent,
zIndexInOverlay = if (isEvent) 1f else 0f,
) )
} }
} }
@@ -2,7 +2,12 @@ package de.jeanlucmakiola.calendula.ui.month
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.indication
import androidx.compose.foundation.LocalIndication
import androidx.compose.animation.EnterExitState import androidx.compose.animation.EnterExitState
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
@@ -11,8 +16,8 @@ import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
import de.jeanlucmakiola.calendula.ui.agenda.agendaDays import de.jeanlucmakiola.calendula.ui.agenda.agendaDays
import de.jeanlucmakiola.calendula.ui.common.FabClearance
import de.jeanlucmakiola.calendula.ui.common.PublishChrome import de.jeanlucmakiola.calendula.ui.common.PublishChrome
import de.jeanlucmakiola.calendula.ui.common.ScreenActions import de.jeanlucmakiola.calendula.ui.common.ScreenActions
import de.jeanlucmakiola.calendula.ui.common.ScreenBody import de.jeanlucmakiola.calendula.ui.common.ScreenBody
@@ -63,6 +68,7 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
@@ -119,11 +125,11 @@ import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
import de.jeanlucmakiola.calendula.ui.common.MoveTarget import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.MoveRequest import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.moveInFlight import de.jeanlucmakiola.calendula.ui.common.dropStates
import de.jeanlucmakiola.calendula.ui.common.abandonedMoves
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
import de.jeanlucmakiola.calendula.ui.common.rememberHeldSettle
import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS
import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
@@ -157,10 +163,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader import de.jeanlucmakiola.calendula.ui.agenda.agendaDayLabel
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
import de.jeanlucmakiola.calendula.ui.agenda.agendaDayLabel
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
@@ -182,7 +187,6 @@ import de.jeanlucmakiola.floret.identity.itemEnter
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
@@ -200,6 +204,7 @@ import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
import kotlinx.datetime.toJavaMonth import kotlinx.datetime.toJavaMonth
import kotlinx.datetime.yearMonth import kotlinx.datetime.yearMonth
import de.jeanlucmakiola.calendula.ui.common.rememberRowWeekNumber
private val LocalTodayPulse = staticCompositionLocalOf { 0 } private val LocalTodayPulse = staticCompositionLocalOf { 0 }
@@ -278,6 +283,8 @@ fun MonthScreen(
val selectedDate by viewModel.selectedDate.collectAsStateWithLifecycle() val selectedDate by viewModel.selectedDate.collectAsStateWithLifecycle()
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle() val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle() val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle()
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val showStartTimes by viewModel.showStartTimes.collectAsStateWithLifecycle()
// The instant before which an event counts as completed, or null when dimming // The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the // is off. derivedStateOf keeps the per-minute "now" from recomposing the
// screen while the setting is off (it stays null regardless of the tick). // screen while the setting is off (it stays null regardless of the tick).
@@ -363,7 +370,7 @@ fun MonthScreen(
if (!scrolling) return@LaunchedEffect if (!scrolling) return@LaunchedEffect
snapshotFlow { snapshotFlow {
// Empty before the first layout pass — and reporting a default of 0 // Empty before the first layout pass — and reporting a default of 0
// for it would claim January 1900 is on screen and drag the whole // for it would claim the span's first month is on screen and drag the whole
// window back there. // window back there.
val visible = listState.layoutInfo.visibleItemsInfo val visible = listState.layoutInfo.visibleItemsInfo
when { when {
@@ -448,7 +455,8 @@ fun MonthScreen(
} }
} }
// Drawer jump-to-date. // Drawer jump-to-date.
val jumpToDate: (LocalDate) -> Unit = { target -> val jumpToDate: (LocalDate) -> Unit = { date ->
val target = coerceToMonthRange(date)
if (scrolling) { if (scrolling) {
val targetMonth = target.yearMonth val targetMonth = target.yearMonth
pendingScrollMonth = targetMonth pendingScrollMonth = targetMonth
@@ -507,7 +515,7 @@ fun MonthScreen(
val chipDrag = rememberMonthDragController() val chipDrag = rememberMonthDragController()
// Undo moves the event back, so the chip travels back too. The // Undo moves the event back, so the chip travels back too. The
// signal comes from the write, not from the chip that offers it. // signal comes from the write, not from the chip that offers it.
OnUndoStarted { chipDrag.beginUndo() } OnUndoStarted { start -> chipDrag.beginUndo(start.token, start.dropToken) }
Box( Box(
modifier = Modifier modifier = Modifier
.padding(innerPadding) .padding(innerPadding)
@@ -524,17 +532,20 @@ fun MonthScreen(
) { ) {
if (scrolling) { if (scrolling) {
ContinuousMonthContent( ContinuousMonthContent(
state = continuousState, // Held on the skeleton until the start-time setting has
// loaded, so chips don't redraw without their times.
state = if (showStartTimes == null) ContinuousMonthUiState.Loading else continuousState,
listState = listState, listState = listState,
dense = dense, dense = dense,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
showStartTimes = showStartTimes == true,
onRetry = jumpToToday, onRetry = jumpToToday,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
) )
} else { } else {
SplitMonthContent( SplitMonthContent(
state = state, state = if (showStartTimes == null) MonthUiState.Loading else state,
selected = selectedDate, selected = selectedDate,
initiallyExpanded = viewStyle == MonthViewStyle.Paged, initiallyExpanded = viewStyle == MonthViewStyle.Paged,
expanded = splitExpanded, expanded = splitExpanded,
@@ -543,6 +554,8 @@ fun MonthScreen(
weekStart = weekStart, weekStart = weekStart,
monthState = viewModel::month, monthState = viewModel::month,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
showStartTimes = showStartTimes == true,
showToday = showToday,
onPaneSwipeNext = { viewModel.stepSplitPane(1) }, onPaneSwipeNext = { viewModel.stepSplitPane(1) },
onPaneSwipePrev = { viewModel.stepSplitPane(-1) }, onPaneSwipePrev = { viewModel.stepSplitPane(-1) },
onClearSelection = viewModel::clearSelection, onClearSelection = viewModel::clearSelection,
@@ -551,8 +564,10 @@ fun MonthScreen(
// month, pager and all — straight away, or a swipe // month, pager and all — straight away, or a swipe
// still settling would land and take the tap back. // still settling would land and take the tap back.
onSelectDay = { date -> onSelectDay = { date ->
viewModel.selectDate(date) if (isInMonthRange(date)) {
jumpToMonth(date.yearMonth) viewModel.selectDate(date)
jumpToMonth(date.yearMonth)
}
}, },
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -561,7 +576,7 @@ fun MonthScreen(
} }
} }
} }
MonthDragOverlay(chipDrag) MonthDragOverlay(chipDrag, showStartTimes = showStartTimes == true)
} }
} }
} }
@@ -574,13 +589,13 @@ private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
/** The chip in flight, drawn over the grid and following the finger. */ /** The chip in flight, drawn over the grid and following the finger. */
@Composable @Composable
private fun MonthDragOverlay(controller: MonthDragController) { private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Boolean) {
var origin by remember { mutableStateOf(Offset.Zero) } var origin by remember { mutableStateOf(Offset.Zero) }
val dark = LocalFloretDarkTheme.current val timeFit = rememberMonthChipTimeFit(showStartTimes)
val density = LocalDensity.current
val timeChipWidth = rememberMonthTimeChipWidth()
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
val moveInFlight = moveInFlight() val drops = dropStates()
val moveInFlight = controller.settledToken in drops.pending
val abandoned = controller.settledToken in drops.abandoned
// Here rather than beside the controller: this reads [drag], which changes // Here rather than beside the controller: this reads [drag], which changes
// every frame, and this composable is the one that is meant to. // every frame, and this composable is the one that is meant to.
DragSnapHaptics(controller.drag?.targetDate, controller.drops) DragSnapHaptics(controller.drag?.targetDate, controller.drops)
@@ -593,14 +608,12 @@ private fun MonthDragOverlay(controller: MonthDragController) {
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) } val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>() val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
val seat = controller.settledInRoot val seat = controller.settledInRoot
val abandoned = abandonedMoves()
LaunchedEffect(controller.settling, moveInFlight, seat, abandoned) { LaunchedEffect(controller.settling, moveInFlight, seat, abandoned) {
val landed = controller.settling val landed = controller.settling
if (landed == null || moveInFlight) return@LaunchedEffect if (landed == null || moveInFlight) return@LaunchedEffect
if (seat == null) { if (seat == null) {
// Past the mark this drop was let go on, it was abandoned and no // An abandoned drop has no seat coming.
// seat is ever coming. if (!abandoned) delay(MONTH_SETTLE_TIMEOUT_MILLIS)
if (abandoned == controller.settledAbandonedMark) delay(MONTH_SETTLE_TIMEOUT_MILLIS)
} else { } else {
if (!gliding) glide.snapTo(landed.topLeftInRoot) if (!gliding) glide.snapTo(landed.topLeftInRoot)
gliding = true gliding = true
@@ -620,6 +633,9 @@ private fun MonthDragOverlay(controller: MonthDragController) {
.clearAndSetSemantics { } .clearAndSetSemantics { }
.onGloballyPositioned { origin = it.positionInRoot() }, .onGloballyPositioned { origin = it.positionInRoot() },
) { ) {
controller.held.forEach { parked ->
key(parked.token) { HeldMonthChip(controller, parked, origin, timeFit) }
}
val drag = controller.drag ?: controller.settling ?: return@Box val drag = controller.drag ?: controller.settling ?: return@Box
// Landed: the copy sinks back to the grid's own plane while the write runs. // Landed: the copy sinks back to the grid's own plane while the write runs.
val lift by animateFloatAsState( val lift by animateFloatAsState(
@@ -631,41 +647,93 @@ private fun MonthDragOverlay(controller: MonthDragController) {
animationSpec = tween(SETTLE_FADE_MILLIS), animationSpec = tween(SETTLE_FADE_MILLIS),
label = "chip-handover", label = "chip-handover",
) )
MonthBar( MonthChipCopy(
event = drag.event, drag = drag,
dark = dark, // Read at layout, so the glide doesn't recompose every frame.
continuesLeft = false, at = { if (gliding) glide.value else drag.topLeftInRoot },
continuesRight = false, origin = origin,
// The time the source chip carried, drawn only if this one-column lift = lift,
// cut-out of it has the room — which a slice of a multi-day bar has alpha = copyAlpha,
// not, however wide the bar was. timeFit = timeFit,
time = drag.time,
showTime = with(density) { drag.sizePx.width.toDp() } >= timeChipWidth,
modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware
// offset would mirror them across the screen in an RTL layout.
.absoluteOffset {
val at = if (gliding) glide.value else drag.topLeftInRoot
IntOffset(
(at.x - origin.x).roundToInt(),
(at.y - origin.y).roundToInt(),
)
}
.width(with(density) { drag.sizePx.width.toDp() })
.height(with(density) { drag.sizePx.height.toDp() })
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp)
.graphicsLayer {
scaleX = 1f + 0.04f * lift
scaleY = 1f + 0.04f * lift
shadowElevation = 8.dp.toPx() * lift
alpha = copyAlpha
shape = EventChipShape
clip = false
},
) )
} }
} }
/** A drop parked by a newer pickup: held on its landing day until its own write settles. */
@Composable
private fun HeldMonthChip(
controller: MonthDragController,
parked: HeldChip,
origin: Offset,
timeFit: MonthChipTimeFit?,
) {
val reduceMotion = rememberReduceMotion()
val glide = remember { Animatable(parked.copy.topLeftInRoot, Offset.VectorConverter) }
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
val seat = parked.seat
val landed = parked.token !in dropStates().pending
// Carried onto its seat once the write is in, as the settling chip is.
LaunchedEffect(seat, landed) {
if (seat == null || !landed) return@LaunchedEffect
if (reduceMotion) glide.snapTo(seat) else glide.animateTo(seat, glideSpec)
}
val alpha = rememberHeldSettle(
token = parked.token,
seated = seat != null,
timeoutMillis = MONTH_SETTLE_TIMEOUT_MILLIS,
onHandOver = { controller.handOverHeld(parked.token) },
onRelease = { controller.releaseHeld(parked.token) },
)
MonthChipCopy(parked.copy, { glide.value }, origin, lift = 0f, alpha = alpha, timeFit = timeFit)
}
/** [drag]'s chip floating over the grid with its top left at [at], in root coordinates. */
@Composable
private fun MonthChipCopy(
drag: MonthChipDrag,
at: () -> Offset,
origin: Offset,
lift: Float,
alpha: Float,
timeFit: MonthChipTimeFit?,
) {
val density = LocalDensity.current
val dark = LocalFloretDarkTheme.current
MonthBar(
event = drag.event,
dark = dark,
continuesLeft = false,
continuesRight = false,
// The time the source chip carried, drawn only if this one-column
// cut-out of it has the room — which a slice of a multi-day bar has
// not, however wide the bar was.
time = drag.time,
timeFit = timeFit,
chipWidth = with(density) { drag.sizePx.width.toDp() },
modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware
// offset would mirror them across the screen in an RTL layout.
.absoluteOffset {
val topLeft = at()
IntOffset(
(topLeft.x - origin.x).roundToInt(),
(topLeft.y - origin.y).roundToInt(),
)
}
.width(with(density) { drag.sizePx.width.toDp() })
.height(with(density) { drag.sizePx.height.toDp() })
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp)
.graphicsLayer {
scaleX = 1f + 0.04f * lift
scaleY = 1f + 0.04f * lift
shadowElevation = 8.dp.toPx() * lift
this.alpha = alpha
shape = EventChipShape
clip = false
},
)
}
/** /**
* Content for both scrolling styles. No swipe detector and no [AnimatedContent]: * Content for both scrolling styles. No swipe detector and no [AnimatedContent]:
* vertical scrolling owns the gesture, and the list never swaps wholesale — it * vertical scrolling owns the gesture, and the list never swaps wholesale — it
@@ -677,6 +745,7 @@ private fun ContinuousMonthContent(
listState: LazyListState, listState: LazyListState,
dense: Boolean, dense: Boolean,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
showStartTimes: Boolean,
onRetry: () -> Unit, onRetry: () -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
@@ -693,6 +762,7 @@ private fun ContinuousMonthContent(
state = state, state = state,
listState = listState, listState = listState,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
showStartTimes = showStartTimes,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
) )
@@ -701,6 +771,7 @@ private fun ContinuousMonthContent(
state = state, state = state,
listState = listState, listState = listState,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
showStartTimes = showStartTimes,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
) )
@@ -873,6 +944,7 @@ private val DENSE_HEADER_GAP = 4.dp
internal fun MonthGrid( internal fun MonthGrid(
state: MonthUiState.Success, state: MonthUiState.Success,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
showStartTimes: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */ /** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
@@ -897,13 +969,13 @@ internal fun MonthGrid(
val month = state.month val month = state.month
// Once per grid: the value depends on the typography, the density, the // Once per grid: the value depends on the typography, the density, the
// locale and the 24-hour setting, none of which vary by row (#219). // locale and the 24-hour setting, none of which vary by row (#219).
val timeChipWidth = rememberMonthTimeChipWidth() val timeFit = rememberMonthChipTimeFit(showStartTimes)
state.weeks.forEach { week -> state.weeks.forEach { week ->
MonthWeekRow( MonthWeekRow(
week = week, week = week,
today = state.today, today = state.today,
zone = state.zone, zone = state.zone,
timeChipWidth = timeChipWidth, timeFit = timeFit,
rowHeight = rowHeight, rowHeight = rowHeight,
inMonth = { it.month == month.month && it.year == month.year }, inMonth = { it.month == month.month && it.year == month.year },
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
@@ -939,18 +1011,19 @@ internal fun ContinuousMonthGrid(
state: ContinuousMonthUiState.Success, state: ContinuousMonthUiState.Success,
listState: LazyListState, listState: LazyListState,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
showStartTimes: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val monthCount = remember { continuousMonthCount() } val monthCount = remember { continuousMonthCount() }
val todayMonth = remember(state.today) { state.today.yearMonth } val todayMonth = remember(state.today) { state.today.yearMonth }
val timeChipWidth = rememberMonthTimeChipWidth() val timeFit = rememberMonthChipTimeFit(showStartTimes)
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
// Bottom inset clears the FAB stack so the last row stays tappable. // Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(bottom = 96.dp), contentPadding = PaddingValues(bottom = FabClearance),
) { ) {
repeat(monthCount) { index -> repeat(monthCount) { index ->
val month = yearMonthForIndex(index) val month = yearMonthForIndex(index)
@@ -968,7 +1041,7 @@ internal fun ContinuousMonthGrid(
weekStart = state.weekStart, weekStart = state.weekStart,
today = state.today, today = state.today,
zone = state.zone, zone = state.zone,
timeChipWidth = timeChipWidth, timeFit = timeFit,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -990,7 +1063,7 @@ private fun ContinuousMonthBlock(
weekStart: DayOfWeek, weekStart: DayOfWeek,
today: LocalDate, today: LocalDate,
zone: TimeZone, zone: TimeZone,
timeChipWidth: Dp, timeFit: MonthChipTimeFit?,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
@@ -1011,7 +1084,7 @@ private fun ContinuousMonthBlock(
week = week, week = week,
today = today, today = today,
zone = zone, zone = zone,
timeChipWidth = timeChipWidth, timeFit = timeFit,
rowHeight = CONTINUOUS_ROW_HEIGHT, rowHeight = CONTINUOUS_ROW_HEIGHT,
inMonth = { it.month == month.month && it.year == month.year }, inMonth = { it.month == month.month && it.year == month.year },
// The block owns its month alone: a day from either // The block owns its month alone: a day from either
@@ -1077,12 +1150,13 @@ internal fun DenseMonthGrid(
state: ContinuousMonthUiState.Success, state: ContinuousMonthUiState.Success,
listState: LazyListState, listState: LazyListState,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
showStartTimes: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) } val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
val timeChipWidth = rememberMonthTimeChipWidth() val timeFit = rememberMonthChipTimeFit(showStartTimes)
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = modifier modifier = modifier
@@ -1095,7 +1169,7 @@ internal fun DenseMonthGrid(
.padding(top = DENSE_HEADER_GAP), .padding(top = DENSE_HEADER_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
// Bottom inset clears the FAB stack so the last row stays tappable. // Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(bottom = 96.dp), contentPadding = PaddingValues(bottom = FabClearance),
) { ) {
items(count = weekCount, key = { it }) { index -> items(count = weekCount, key = { it }) { index ->
val week = state.weeksByIndex[index] val week = state.weeksByIndex[index]
@@ -1106,7 +1180,7 @@ internal fun DenseMonthGrid(
week = week, week = week,
today = state.today, today = state.today,
zone = state.zone, zone = state.zone,
timeChipWidth = timeChipWidth, timeFit = timeFit,
rowHeight = CONTINUOUS_ROW_HEIGHT, rowHeight = CONTINUOUS_ROW_HEIGHT,
// Every day in the stream belongs to a month equally — there // Every day in the stream belongs to a month equally — there
// is no "other month" to recede here. // is no "other month" to recede here.
@@ -1198,6 +1272,8 @@ private fun SplitMonthContent(
weekStart: DayOfWeek, weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>, monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
showStartTimes: Boolean,
showToday: Boolean,
onPaneSwipeNext: () -> Unit, onPaneSwipeNext: () -> Unit,
onPaneSwipePrev: () -> Unit, onPaneSwipePrev: () -> Unit,
onClearSelection: () -> Unit, onClearSelection: () -> Unit,
@@ -1207,9 +1283,12 @@ private fun SplitMonthContent(
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit, onCreateEvent: (LocalDate) -> Unit,
) { ) {
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
// Registered first so the expand handler below wins while the grid is open.
BackHandler(enabled = selected != null && !expanded) {
onClearSelection()
}
BackHandler(enabled = expanded != initiallyExpanded) { BackHandler(enabled = expanded != initiallyExpanded) {
onExpandedChange(initiallyExpanded) onExpandedChange(initiallyExpanded)
} }
@@ -1278,8 +1357,9 @@ private fun SplitMonthContent(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
// Both faces fill the same page — the grid grows into exactly the // Both faces fill the same page — the grid grows into exactly the
// room the pane gives up — so there is no size change to contain. // room the pane gives up — so there is no size change to contain.
// The travel between them is the shared elements' job. // Fading the faces made the shared cell backgrounds briefly
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) }, // disappear beneath their incoming counterparts.
transitionSpec = { EnterTransition.None togetherWith ExitTransition.None },
label = "split-expand", label = "split-expand",
) { isExpanded -> ) { isExpanded ->
// The view switch's morph stays off a face until it has fully // The view switch's morph stays off a face until it has fully
@@ -1310,6 +1390,7 @@ private fun SplitMonthContent(
state = page, state = page,
selected = pageSelected, selected = pageSelected,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
showStartTimes = showStartTimes,
expandDrag = expandDrag, expandDrag = expandDrag,
onPickDay = onPickDay, onPickDay = onPickDay,
// A tapped chip is asking for that event, not for its // A tapped chip is asking for that event, not for its
@@ -1335,7 +1416,11 @@ private fun SplitMonthContent(
state = page, state = page,
paneReady = paneReady, paneReady = paneReady,
selected = pageSelected, selected = pageSelected,
selectionIndicatorVisible = reduceMotion ||
(transition.currentState == EnterExitState.Visible &&
transition.targetState == EnterExitState.Visible),
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
showToday = showToday,
expandDrag = expandDrag, expandDrag = expandDrag,
onSelectDay = onSelectDay, onSelectDay = onSelectDay,
onPaneSwipeNext = { onPaneSwipeNext = {
@@ -1377,7 +1462,9 @@ private fun SplitMonthCollapsed(
state: MonthUiState.Success, state: MonthUiState.Success,
paneReady: Boolean, paneReady: Boolean,
selected: LocalDate?, selected: LocalDate?,
selectionIndicatorVisible: Boolean,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
showToday: Boolean,
expandDrag: Modifier, expandDrag: Modifier,
onSelectDay: (LocalDate) -> Unit, onSelectDay: (LocalDate) -> Unit,
onPaneSwipeNext: () -> Unit, onPaneSwipeNext: () -> Unit,
@@ -1397,6 +1484,7 @@ private fun SplitMonthCollapsed(
SplitMonthGrid( SplitMonthGrid(
state = state, state = state,
selected = selected, selected = selected,
selectionIndicatorVisible = selectionIndicatorVisible,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onSelectDay = onSelectDay, onSelectDay = onSelectDay,
modifier = Modifier.height(splitGridHeight(state.weeks.size)), modifier = Modifier.height(splitGridHeight(state.weeks.size)),
@@ -1410,6 +1498,7 @@ private fun SplitMonthCollapsed(
Box(Modifier.weight(1f).fillMaxWidth().then(paneModifier)) { Box(Modifier.weight(1f).fillMaxWidth().then(paneModifier)) {
SplitMonthAgendaPane( SplitMonthAgendaPane(
state = state, state = state,
showToday = showToday,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
@@ -1423,24 +1512,13 @@ private fun SplitMonthCollapsed(
.then(paneModifier) .then(paneModifier)
.then(rememberSplitPaneSwipe(onPaneSwipeNext, onPaneSwipePrev)), .then(rememberSplitPaneSwipe(onPaneSwipeNext, onPaneSwipePrev)),
) { ) {
InputChip(
selected = true,
onClick = onClearSelection,
label = { Text(agendaDayLabel(selected, state.today)) },
trailingIcon = {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.month_split_clear_filter),
)
},
modifier = Modifier.padding(start = 16.dp),
)
SplitDayPane( SplitDayPane(
date = selected, date = selected,
today = state.today, today = state.today,
events = state.instancesByDay[selected], events = state.instancesByDay[selected],
zone = state.zone, zone = state.zone,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onClearSelection = onClearSelection,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
modifier = Modifier.weight(1f).fillMaxWidth(), modifier = Modifier.weight(1f).fillMaxWidth(),
@@ -1487,6 +1565,7 @@ private fun SplitMonthExpanded(
/** Marked nowhere here; it only anchors the outline's morph. */ /** Marked nowhere here; it only anchors the outline's morph. */
selected: LocalDate?, selected: LocalDate?,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
showStartTimes: Boolean,
expandDrag: Modifier, expandDrag: Modifier,
onPickDay: (LocalDate) -> Unit, onPickDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
@@ -1499,6 +1578,7 @@ private fun SplitMonthExpanded(
MonthGrid( MonthGrid(
state = state, state = state,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
showStartTimes = showStartTimes,
onOpenDay = onPickDay, onOpenDay = onPickDay,
onEventClick = onEventClick, onEventClick = onEventClick,
selected = selected, selected = selected,
@@ -1612,6 +1692,7 @@ private val SPLIT_PANE_EASING = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
internal fun SplitMonthGrid( internal fun SplitMonthGrid(
state: MonthUiState.Success, state: MonthUiState.Success,
selected: LocalDate?, selected: LocalDate?,
selectionIndicatorVisible: Boolean = true,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onSelectDay: (LocalDate) -> Unit, onSelectDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -1657,6 +1738,7 @@ internal fun SplitMonthGrid(
// removes that without timing anything against the // removes that without timing anything against the
// page transition. // page transition.
isSelected = day == selected && inMonth, isSelected = day == selected && inMonth,
selectionIndicatorVisible = selectionIndicatorVisible,
inMonth = inMonth, inMonth = inMonth,
relativeToToday = todayCol?.let { col - it }, relativeToToday = todayCol?.let { col - it },
rippleProgress = { ripple.value }, rippleProgress = { ripple.value },
@@ -1685,6 +1767,7 @@ private fun SplitDayCell(
hidden: List<EventInstance>, hidden: List<EventInstance>,
isToday: Boolean, isToday: Boolean,
isSelected: Boolean, isSelected: Boolean,
selectionIndicatorVisible: Boolean,
inMonth: Boolean, inMonth: Boolean,
relativeToToday: Int?, relativeToToday: Int?,
rippleProgress: () -> Float, rippleProgress: () -> Float,
@@ -1714,7 +1797,7 @@ private fun SplitDayCell(
// pace it lingered after the thing it marks had already moved. // pace it lingered after the thing it marks had already moved.
val outlineSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>() val outlineSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
val selection = animateFloatAsState( val selection = animateFloatAsState(
targetValue = if (isSelected) 1f else 0f, targetValue = if (isSelected && selectionIndicatorVisible) 1f else 0f,
animationSpec = if (reduceMotion) snap() else outlineSpec, animationSpec = if (reduceMotion) snap() else outlineSpec,
label = "split-day-selection", label = "split-day-selection",
) )
@@ -1726,12 +1809,19 @@ private fun SplitDayCell(
// built — and so the pill can be tagged for the morph on its own. Tagged as // built — and so the pill can be tagged for the morph on its own. Tagged as
// one piece with its contents inside, the dots would be nested shared // one piece with its contents inside, the dots would be nested shared
// elements within a shared element and travel twice. // elements within a shared element and travel twice.
// The cell keeps the click and selection semantics, so TalkBack reads the
// day number with them; the topmost layer only draws the ripple.
val interaction = remember { MutableInteractionSource() }
Box( Box(
modifier = modifier modifier = modifier
.padding(horizontal = CELL_GAP, vertical = 1.dp) .padding(horizontal = CELL_GAP, vertical = 1.dp)
.todayRipple(relativeToToday, rippleProgress) .todayRipple(relativeToToday, rippleProgress)
.clip(CELL_SHAPE) .selectable(
.selectable(selected = isSelected, onClick = onClick), selected = isSelected,
interactionSource = interaction,
indication = null,
onClick = onClick,
),
contentAlignment = Alignment.TopCenter, contentAlignment = Alignment.TopCenter,
) { ) {
Box( Box(
@@ -1793,6 +1883,13 @@ private fun SplitDayCell(
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
SplitDots(date = date, events = events, hidden = hidden, dark = dark) SplitDots(date = date, events = events, hidden = hidden, dark = dark)
} }
Box(
Modifier
.fillMaxSize()
.morphElement(MonthMorphKey.CellRipple(date))
.clip(CELL_SHAPE)
.indication(interaction, LocalIndication.current),
)
} }
} }
@@ -1814,7 +1911,10 @@ private fun SplitDots(
if (events.isEmpty()) return if (events.isEmpty()) return
val soften = LocalSoftenColors.current val soften = LocalSoftenColors.current
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
// Held to the dots' height so a "+N" doesn't grow the row and push the dots
// down; the label overflows it evenly instead (#142).
Row( Row(
modifier = Modifier.height(SPLIT_DOT_SIZE),
horizontalArrangement = Arrangement.spacedBy(2.dp), horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -1849,6 +1949,7 @@ private fun SplitDots(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier modifier = Modifier
.wrapContentHeight(unbounded = true)
.morphBounds(MonthMorphKey.Overflow(date)) .morphBounds(MonthMorphKey.Overflow(date))
.alpha(if (allEnded(hidden, date, dimCutoff)) EventDimAlpha else 1f), .alpha(if (allEnded(hidden, date, dimCutoff)) EventDimAlpha else 1f),
) )
@@ -1859,22 +1960,33 @@ private fun SplitDots(
@Composable @Composable
private fun SplitMonthAgendaPane( private fun SplitMonthAgendaPane(
state: MonthUiState.Success, state: MonthUiState.Success,
showToday: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit, onCreateEvent: (LocalDate) -> Unit,
) { ) {
val start = agendaStartForMonth(state.month, state.today) val days = remember(state.month, state.today, state.instancesByDay, showToday) {
val days = state.instancesByDay.keys splitAgendaDays(state.month, state.today, state.instancesByDay, showToday)
.filter { it >= start && it.year == state.month.year && it.month == state.month.month } }
.filter { state.instancesByDay[it].orEmpty().isNotEmpty() }
.toMutableList()
.apply { if (start !in this) add(start) }
.sorted()
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
val listState = key(state.month) { rememberLazyListState() } val listState = key(state.month) { rememberLazyListState() }
LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) { LazyColumn(state = listState, contentPadding = PaddingValues(bottom = FabClearance)) {
// A fixed first item, so a day added above the first listed one shows
// up rather than landing above a list that holds its scroll position.
item(key = "top") { Spacer(Modifier.height(4.dp)) }
if (days.isEmpty()) {
val start = agendaStartForMonth(state.month, state.today)
item(key = "empty") {
AgendaEmptyDayRow(
text = stringResource(
if (start == state.today) R.string.month_split_no_more_events else R.string.month_split_no_events_month,
),
onClick = { onCreateEvent(start) },
)
}
}
agendaDays( agendaDays(
days = days.map { AgendaDay(it, state.instancesByDay[it].orEmpty()) }, days = days,
today = state.today, today = state.today,
zone = state.zone, zone = state.zone,
emptyText = R.string.month_split_no_events, emptyText = R.string.month_split_no_events,
@@ -1882,6 +1994,7 @@ private fun SplitMonthAgendaPane(
dimmed = { event, day -> dimCutoff != null && event.isPastOn(day, dimCutoff) }, dimmed = { event, day -> dimCutoff != null && event.isPastOn(day, dimCutoff) },
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
compact = true,
) )
} }
} }
@@ -1889,7 +2002,8 @@ private fun SplitMonthAgendaPane(
/** /**
* The selected day's events, in the agenda's own row vocabulary so the two * The selected day's events, in the agenda's own row vocabulary so the two
* surfaces read as one app. The date header opens the full Day view, matching * surfaces read as one app. The date header opens the full Day view, matching
* the Week and Agenda headers (#37). * the Week and Agenda headers (#37). It is shown as the filter chip itself,
* whose ✕ clears the day filter back to the whole month (#142).
*/ */
@Composable @Composable
internal fun SplitDayPane( internal fun SplitDayPane(
@@ -1899,6 +2013,7 @@ internal fun SplitDayPane(
events: List<EventInstance>?, events: List<EventInstance>?,
zone: TimeZone, zone: TimeZone,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onClearSelection: () -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit, onCreateEvent: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -1910,7 +2025,23 @@ internal fun SplitDayPane(
val enter = itemEnter() val enter = itemEnter()
val fadeSpec = rememberCalendarFadeSpec() val fadeSpec = rememberCalendarFadeSpec()
Column(modifier = modifier) { Column(modifier = modifier) {
AgendaDayHeader(date = date, today = today, onOpenDay = onOpenDay) InputChip(
selected = true,
onClick = { onOpenDay(date) },
label = {
Text(agendaDayLabel(date, today), style = MaterialTheme.typography.titleSmall)
},
trailingIcon = {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.month_split_clear_filter),
modifier = Modifier
.clip(CircleShape)
.clickable(onClick = onClearSelection),
)
},
modifier = Modifier.padding(start = 16.dp),
)
AnimatedContent( AnimatedContent(
targetState = date to events, targetState = date to events,
modifier = Modifier.weight(1f).fillMaxWidth(), modifier = Modifier.weight(1f).fillMaxWidth(),
@@ -1933,7 +2064,7 @@ internal fun SplitDayPane(
else -> LazyColumn( else -> LazyColumn(
// Bottom inset clears the FAB stack so the last row stays // Bottom inset clears the FAB stack so the last row stays
// tappable. // tappable.
contentPadding = PaddingValues(bottom = 96.dp), contentPadding = PaddingValues(bottom = FabClearance),
) { ) {
itemsIndexed( itemsIndexed(
items = dayEvents, items = dayEvents,
@@ -2089,8 +2220,8 @@ private fun MonthWeekRow(
today: LocalDate, today: LocalDate,
/** The zone the grid was laid out in — never re-read here (see [MonthUiState.Success.zone]). */ /** The zone the grid was laid out in — never re-read here (see [MonthUiState.Success.zone]). */
zone: TimeZone, zone: TimeZone,
/** The narrowest chip that may carry a start time, measured once per grid (#219). */ /** Prices chips' start times, once per grid; null when they are switched off (#219, #476). */
timeChipWidth: Dp, timeFit: MonthChipTimeFit?,
/** The height this row was given, which decides its lanes — see [laneCapFor]. */ /** The height this row was given, which decides its lanes — see [laneCapFor]. */
rowHeight: Dp, rowHeight: Dp,
inMonth: (LocalDate) -> Boolean, inMonth: (LocalDate) -> Boolean,
@@ -2179,7 +2310,7 @@ private fun MonthWeekRow(
// Once the grid holds the settled chip, tell the controller where this row // Once the grid holds the settled chip, tell the controller where this row
// has seated it, so the copy in flight can glide onto it. Keyed on the week, // has seated it, so the copy in flight can glide onto it. Keyed on the week,
// which is what changes when the re-read lands. // which is what changes when the re-read lands.
LaunchedEffect(week, dragController?.settling) { LaunchedEffect(week, dragController?.settling, dragController?.held) {
dragController?.noteSettled(rowToken) dragController?.noteSettled(rowToken)
} }
@@ -2216,8 +2347,8 @@ private fun MonthWeekRow(
), ),
), ),
) { ) {
// What a chip has to spend, against the [timeChipWidth] a start time // What a chip has to spend, against what [timeFit] says its start
// costs it (#219). // time costs it (#219).
val colW = maxWidth / 7 val colW = maxWidth / 7
// Held here rather than read at the offset: the dots are placed // Held here rather than read at the offset: the dots are placed
// inside a plain lambda, which is no longer in this scope. The box // inside a plain lambda, which is no longer in this scope. The box
@@ -2307,7 +2438,8 @@ private fun MonthWeekRow(
continuesRight = span.continuesRight, continuesRight = span.continuesRight,
days = week.days.subList(span.startCol, span.endCol + 1), days = week.days.subList(span.startCol, span.endCol + 1),
time = chipTimes[span.event.instanceId], time = chipTimes[span.event.instanceId],
showTime = colW * cols >= timeChipWidth, timeFit = timeFit,
chipWidth = colW * cols,
modifier = Modifier modifier = Modifier
.offset( .offset(
x = colW * span.startCol, x = colW * span.startCol,
@@ -2384,7 +2516,8 @@ private fun MonthWeekRow(
continuesRight = false, continuesRight = false,
days = listOf(d), days = listOf(d),
time = chipTimes[ev.instanceId], time = chipTimes[ev.instanceId],
showTime = colW >= timeChipWidth, timeFit = timeFit,
chipWidth = colW,
modifier = Modifier modifier = Modifier
.offset( .offset(
x = colW * col, x = colW * col,
@@ -2482,6 +2615,7 @@ private fun MonthWeekRow(
} }
} }
.padding(horizontal = CELL_GAP, vertical = 1.dp) .padding(horizontal = CELL_GAP, vertical = 1.dp)
.morphElement(MonthMorphKey.CellRipple(d))
.clip(CELL_SHAPE) .clip(CELL_SHAPE)
.clickable { .clickable {
// Cleared on read: a click with no fresh down // Cleared on read: a click with no fresh down
@@ -2609,6 +2743,8 @@ private fun monthChipDragModifier(
beginMillis = drop.event.start.toEpochMilliseconds(), beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(), endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.ByDays(delta), target = MoveTarget.ByDays(delta),
isAllDay = drop.event.isAllDay,
token = drop.token,
), ),
) )
} }
@@ -2619,19 +2755,20 @@ private fun monthChipDragModifier(
/** /**
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the * Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
* day cells' geometry, set apart by the secondaryContainer tint (matching the * day cells' geometry, set apart by the secondaryContainer tint (matching the
* Week view's badge), with the ISO week number centred like a day number. The * Week view's badge), with the week number centred like a day number. It is
* week is computed on the row's first day — the same basis as the Week view — so * numbered by the device region from the row's mid-week day, as in the Week
* the two agree. * view, so it is right for any first weekday (#493).
*/ */
@Composable @Composable
private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) { private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) {
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() } val weekNumber = rememberRowWeekNumber(weekStart)
val label = stringResource(R.string.week_number_label) val label = stringResource(R.string.week_number_label)
val openLabel = stringResource(R.string.month_open_week, weekNumber) val openLabel = stringResource(R.string.month_open_week, weekNumber)
val onOpenWeek = LocalOpenWeek.current val onOpenWeek = LocalOpenWeek.current
Box( Box(
modifier = modifier modifier = modifier
.padding(horizontal = CELL_GAP, vertical = 1.dp) .padding(horizontal = CELL_GAP, vertical = 1.dp)
.morphElement(MonthMorphKey.WeekNumber(weekStart))
.clip(CELL_SHAPE) .clip(CELL_SHAPE)
.background(MaterialTheme.colorScheme.secondaryContainer) .background(MaterialTheme.colorScheme.secondaryContainer)
.then(if (onOpenWeek != null) Modifier.clickable(onClickLabel = openLabel) { .then(if (onOpenWeek != null) Modifier.clickable(onClickLabel = openLabel) {
@@ -2710,7 +2847,7 @@ private fun shortMonthName(date: LocalDate): String {
/** /**
* A filled event pill/bar — softened (or raw) fill, title clipped to one line, * A filled event pill/bar — softened (or raw) fill, title clipped to one line,
* with the start time before it where the chip is wide enough ([showTime], #219). * with the start time before it where the chip is wide enough ([timeFit], #219, #476).
*/ */
@Composable @Composable
private fun MonthBar( private fun MonthBar(
@@ -2727,10 +2864,13 @@ private fun MonthBar(
days: List<LocalDate>? = null, days: List<LocalDate>? = null,
/** The chip's start time, or null where it has none — see `monthChipTime` (#219). */ /** The chip's start time, or null where it has none — see `monthChipTime` (#219). */
time: String? = null, time: String? = null,
/** Whether this chip has the width to draw [time]; a screen reader gets it either way. */ /** Decides whether [time] is drawn at [chipWidth]; null draws none. A screen reader gets it either way. */
showTime: Boolean = false, timeFit: MonthChipTimeFit? = null,
/** The chip's full width, chrome included. */
chipWidth: Dp = 0.dp,
) { ) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val showTime = time != null && timeFit != null && timeFit.showsTime(chipWidth, title)
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
// A bar is one piece, so it dims once the last day it covers has passed. // A bar is one piece, so it dims once the last day it covers has passed.
val dimmed = dimCutoff != null && val dimmed = dimCutoff != null &&
@@ -2750,8 +2890,17 @@ private fun MonthBar(
val moveAction = eventMoveAction(event) val moveAction = eventMoveAction(event)
// The source stays put as a ghost while its floating copy travels. // The source stays put as a ghost while its floating copy travels.
val monthDrag = LocalMonthDrag.current val monthDrag = LocalMonthDrag.current
val lifted = monthDrag?.liftedInstanceId == event.instanceId || // Derived, so a chip recomposes only when its own answer changes rather than
monthDrag?.isSettledChip(event, days) == true // on every lift, park and release anywhere in the grid.
val lifted by remember(monthDrag, event, days) {
derivedStateOf {
monthDrag != null && (
monthDrag.liftedInstanceId == event.instanceId ||
monthDrag.isSettledChip(event, days) ||
monthDrag.isHeldChip(event, days)
)
}
}
val ghost = ghostAlpha(lifted) val ghost = ghostAlpha(lifted)
val shape = monthBarShape(continuesLeft, continuesRight) val shape = monthBarShape(continuesLeft, continuesRight)
Box( Box(
@@ -31,6 +31,7 @@ import kotlinx.datetime.yearMonth
internal fun MonthStylePreview( internal fun MonthStylePreview(
style: MonthViewStyle, style: MonthViewStyle,
weekStart: DayOfWeek, weekStart: DayOfWeek,
showStartTimes: Boolean,
height: Dp, height: Dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -44,6 +45,7 @@ internal fun MonthStylePreview(
MonthViewStyle.Paged -> MonthGrid( MonthViewStyle.Paged -> MonthGrid(
state = sample.month, state = sample.month,
showWeekNumbers = false, showWeekNumbers = false,
showStartTimes = showStartTimes,
onOpenDay = {}, onOpenDay = {},
onEventClick = {}, onEventClick = {},
) )
@@ -55,6 +57,7 @@ internal fun MonthStylePreview(
), ),
), ),
showWeekNumbers = false, showWeekNumbers = false,
showStartTimes = showStartTimes,
onOpenDay = {}, onOpenDay = {},
onEventClick = {}, onEventClick = {},
) )
@@ -67,6 +70,7 @@ internal fun MonthStylePreview(
initialFirstVisibleItemIndex = weekIndexOf(today, weekStart) - 1, initialFirstVisibleItemIndex = weekIndexOf(today, weekStart) - 1,
), ),
showWeekNumbers = false, showWeekNumbers = false,
showStartTimes = showStartTimes,
onOpenDay = {}, onOpenDay = {},
onEventClick = {}, onEventClick = {},
) )
@@ -83,6 +87,7 @@ internal fun MonthStylePreview(
events = sample.month.instancesByDay[today].orEmpty(), events = sample.month.instancesByDay[today].orEmpty(),
zone = zone, zone = zone,
onOpenDay = {}, onOpenDay = {},
onClearSelection = {},
onEventClick = {}, onEventClick = {},
onCreateEvent = {}, onCreateEvent = {},
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT), modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
@@ -13,11 +13,15 @@ import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
import de.jeanlucmakiola.calendula.ui.common.CalendarSpan
import de.jeanlucmakiola.calendula.ui.common.PageStateCache import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator import de.jeanlucmakiola.calendula.domain.sortedForDisplay
import de.jeanlucmakiola.calendula.ui.week.coversDay import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
@@ -81,6 +85,23 @@ class MonthViewModel @Inject constructor(
initialValue = false, initialValue = false,
) )
/** Whether the split agenda keeps a free today, as the Agenda view does (#35). */
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
.stateWhileSubscribed(
scope = viewModelScope,
initialValue = true,
)
/**
* Whether chips may show their start time before the title (#476; display
* only). Null until the setting has loaded, so the grid can wait for it.
*/
val showStartTimes: StateFlow<Boolean?> = settingsPrefs.monthShowStartTimes
.stateWhileSubscribed(
scope = viewModelScope,
initialValue = null,
)
/** How the grid is laid out and navigated (#38, #53). */ /** How the grid is laid out and navigated (#38, #53). */
val viewStyle: StateFlow<MonthViewStyle> = settingsPrefs.monthViewStyle val viewStyle: StateFlow<MonthViewStyle> = settingsPrefs.monthViewStyle
.stateWhileSubscribed( .stateWhileSubscribed(
@@ -95,7 +116,7 @@ class MonthViewModel @Inject constructor(
val today: StateFlow<LocalDate> = val today: StateFlow<LocalDate> =
todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday) todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday)
private val _month = MutableStateFlow(todayDate.yearMonth) private val _month = MutableStateFlow(yearMonthForIndex(monthIndexOf(todayDate.yearMonth)))
val month: StateFlow<YearMonth> = _month val month: StateFlow<YearMonth> = _month
/** /**
@@ -149,7 +170,7 @@ class MonthViewModel @Inject constructor(
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */ /** The pager came to rest on [page]; follow it unless it is already the anchor's. */
fun onPageSettled(page: Int) { fun onPageSettled(page: Int) {
if (monthIndexOf(_month.value) == page) return if (monthIndexOf(_month.value) == page) return
_month.value = yearMonthForIndex(page) setMonth(yearMonthForIndex(page))
// Moving the grid to another month restores its full agenda. // Moving the grid to another month restores its full agenda.
_selectedDate.value = null _selectedDate.value = null
} }
@@ -298,9 +319,11 @@ class MonthViewModel @Inject constructor(
* silently list a date the grid isn't pointing at. * silently list a date the grid isn't pointing at.
*/ */
fun selectDate(date: LocalDate) { fun selectDate(date: LocalDate) {
// A greyed-out day past either end of the grid has no month to follow to.
if (!isInMonthRange(date)) return
val target = date.yearMonth val target = date.yearMonth
_selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date _selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date
if (target != _month.value) _month.value = target if (target != _month.value) setMonth(target)
} }
fun clearSelection() { _selectedDate.value = null } fun clearSelection() { _selectedDate.value = null }
@@ -309,16 +332,16 @@ class MonthViewModel @Inject constructor(
fun stepSplitPane(days: Int) { fun stepSplitPane(days: Int) {
val selected = _selectedDate.value val selected = _selectedDate.value
if (selected == null) { if (selected == null) {
_month.value = yearMonthForIndex(monthIndexOf(_month.value) + days) setMonth(yearMonthForIndex(monthIndexOf(_month.value) + days))
} else { } else {
val next = selected.plus(days, DateTimeUnit.DAY) val next = coerceToMonthRange(selected.plus(days, DateTimeUnit.DAY))
_selectedDate.value = next _selectedDate.value = next
_month.value = next.yearMonth setMonth(next.yearMonth)
} }
} }
fun goToToday() { fun goToToday() {
_month.value = todayDate.yearMonth setMonth(todayDate.yearMonth)
_selectedDate.value = null _selectedDate.value = null
} }
@@ -330,7 +353,7 @@ class MonthViewModel @Inject constructor(
private fun followToday(old: LocalDate, new: LocalDate) { private fun followToday(old: LocalDate, new: LocalDate) {
if (viewStyle.value.isScrolling) return if (viewStyle.value.isScrolling) return
if (_month.value != old.yearMonth) return if (_month.value != old.yearMonth) return
_month.value = new.yearMonth setMonth(new.yearMonth)
_selectedDate.value = null _selectedDate.value = null
} }
@@ -344,14 +367,24 @@ class MonthViewModel @Inject constructor(
*/ */
fun syncScrollMonth(ym: YearMonth) { fun syncScrollMonth(ym: YearMonth) {
if (_month.value == ym) return if (_month.value == ym) return
_month.value = ym setMonth(ym)
_selectedDate.value = null _selectedDate.value = null
} }
/** Jump to the month containing [date] (drawer jump-to-date). */ /**
* Jump to the month containing [date] (drawer jump-to-date, or the focus
* handed over by another view). The focus itself keeps a date outside the
* grid's span, so the day and week views return to it unchanged.
*/
fun goToDate(date: LocalDate) { fun goToDate(date: LocalDate) {
_month.value = date.yearMonth val target = coerceToMonthRange(date)
_selectedDate.value = date setMonth(target.yearMonth)
_selectedDate.value = target
}
/** Every write to [_month] goes through here, so it can't leave the grid's span (#457). */
private fun setMonth(ym: YearMonth) {
_month.value = yearMonthForIndex(monthIndexOf(ym))
} }
private fun buildState( private fun buildState(
@@ -469,7 +502,7 @@ internal fun layoutCalendarWeek(
// crossing midnight into the next week row covers a single day in each, and // crossing midnight into the next week row covers a single day in each, and
// counting per row drew it as two unrelated pills (#253). // counting per row drew it as two unrelated pills (#253).
val (bars, singles) = weekEvents.partition { ev -> val (bars, singles) = weekEvents.partition { ev ->
ev.isAllDay || ev.spansMultipleDays(zone) ev.coversWholeDays(zone) || ev.spansMultipleDays(zone)
} }
val spans = layoutAllDay(bars, days, zone).map { s -> val spans = layoutAllDay(bars, days, zone).map { s ->
MonthSpan( MonthSpan(
@@ -488,7 +521,7 @@ internal fun layoutCalendarWeek(
// Declined last, so a day that overflows drops the events you said // Declined last, so a day that overflows drops the events you said
// no to before the ones you are actually going to (#230). // no to before the ones you are actually going to (#230).
singles.filter { it.coversDay(d, zone) } singles.filter { it.coversDay(d, zone) }
.sortedWith(compareBy<EventInstance> { it.isDeclined }.thenBy { it.start }) .sortedWith(compareBy<EventInstance> { it.isCrossedOut }.thenBy { it.start })
}, },
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } }, countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
) )
@@ -509,7 +542,7 @@ internal fun instancesByDay(
days.associateWith { day -> days.associateWith { day ->
instances instances
.filter { it.coversDay(day, zone) } .filter { it.coversDay(day, zone) }
.sortedWith(eventDisplayComparator(zone)) .sortedForDisplay(zone)
} }
/** /**
@@ -556,6 +589,25 @@ internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate
LocalDate(month.year, month.month, 1) LocalDate(month.year, month.month, 1)
} }
/**
* The days the split agenda lists for [month]: those from [agendaStartForMonth]
* on that have events, plus a free today when [showToday] is set, as in the
* Agenda view (#142).
*/
internal fun splitAgendaDays(
month: YearMonth,
today: LocalDate,
instancesByDay: Map<LocalDate, List<EventInstance>>,
showToday: Boolean,
): List<AgendaDay> {
val start = agendaStartForMonth(month, today)
val days = instancesByDay.keys
.filter { it >= start && it.yearMonth == month && instancesByDay.getValue(it).isNotEmpty() }
.sorted()
.map { AgendaDay(it, instancesByDay.getValue(it)) }
return anchorTodayIfMissing(days, today, enabled = showToday && start == today)
}
/** /**
* The window to load for a visible range, or null to keep the current one. * The window to load for a visible range, or null to keep the current one.
* *
@@ -578,24 +630,39 @@ internal fun nextLoadWindow(
/** /**
* The continuous grid addresses months by an absolute index, so the list has one * The continuous grid addresses months by an absolute index, so the list has one
* stable, gap-free coordinate space to scroll through and key its items by. * stable, gap-free coordinate space to scroll through and key its items by.
* Index 0 is January 1900; the list runs to [continuousMonthCount]. * Index 0 is the first month of [CalendarSpan]; the list runs to [continuousMonthCount].
* *
* Unlike the week indexing this replaced, the coordinate space doesn't depend on * Unlike the week indexing this replaced, the coordinate space doesn't depend on
* the week-start preference — changing it reflows the rows *inside* a month * the week-start preference — changing it reflows the rows *inside* a month
* block but never moves the block, so a scroll position stays put. * block but never moves the block, so a scroll position stays put.
*/ */
private const val MONTH_INDEX_EPOCH_YEAR = 1900 private val MONTH_INDEX_EPOCH_YEAR = CalendarSpan.FIRST_DAY.year
private const val MONTH_INDEX_END_YEAR = 2100 private val MONTH_INDEX_END_YEAR = CalendarSpan.LAST_DAY.year
/** The first and last day the month and Dense grids can show. */
private val GRID_FIRST_DAY = LocalDate(MONTH_INDEX_EPOCH_YEAR, 1, 1)
private val GRID_LAST_DAY = LocalDate(MONTH_INDEX_END_YEAR, 12, 31)
internal fun monthIndexOf(ym: YearMonth): Int = internal fun monthIndexOf(ym: YearMonth): Int =
(ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal (ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal
internal fun yearMonthForIndex(index: Int): YearMonth = YearMonth( /** The month at [index], held to the grid's span, so a stray index can't throw (#457). */
MONTH_INDEX_EPOCH_YEAR + index / 12, internal fun yearMonthForIndex(index: Int): YearMonth {
Month.entries[index % 12], val held = clampMonthIndex(index)
) return YearMonth(MONTH_INDEX_EPOCH_YEAR + held / 12, Month.entries[held % 12])
}
/** Total months the continuous grid scrolls through (1900 → 2100). */ internal fun clampMonthIndex(index: Int): Int = index.coerceIn(0, continuousMonthCount() - 1)
/**
* Hold [date] inside the span the month grid can show. Every view shares
* [CalendarSpan], but another app can open Calendula on any date (#457).
*/
internal fun coerceToMonthRange(date: LocalDate): LocalDate = date.coerceIn(GRID_FIRST_DAY, GRID_LAST_DAY)
internal fun isInMonthRange(date: LocalDate): Boolean = date in GRID_FIRST_DAY..GRID_LAST_DAY
/** Total months the continuous grid scrolls through. */
internal fun continuousMonthCount(): Int = internal fun continuousMonthCount(): Int =
(MONTH_INDEX_END_YEAR - MONTH_INDEX_EPOCH_YEAR + 1) * 12 (MONTH_INDEX_END_YEAR - MONTH_INDEX_EPOCH_YEAR + 1) * 12
@@ -611,25 +678,23 @@ internal fun monthIndexForItem(itemIndex: Int): Int = itemIndex / 2
/** /**
* Hold a load window inside the months that actually exist. The pad * Hold a load window inside the months that actually exist. The pad
* [nextLoadWindow] adds can otherwise push an index past either end of the * [nextLoadWindow] adds can otherwise push an index past either end of the
* 1900–2100 span, and [yearMonthForIndex] has no month to hand back for one. * span, and [yearMonthForIndex] has no month to hand back for one.
*/ */
internal fun clampMonthWindow(window: IntRange): IntRange { internal fun clampMonthWindow(window: IntRange): IntRange =
val last = continuousMonthCount() - 1 clampMonthIndex(window.first)..clampMonthIndex(window.last)
return window.first.coerceIn(0, last)..window.last.coerceIn(0, last)
}
/** /**
* The Dense style addresses *weeks* by absolute index instead: it has no month * The Dense style addresses *weeks* by absolute index instead: it has no month
* blocks to key by, and a row's identity has to survive the months around it * blocks to key by, and a row's identity has to survive the months around it
* scrolling past. Index 0 is the first week of 1900 under the active week-start, * scrolling past. Index 0 is the span's first week under the active week-start,
* so — like the month space — every index is non-negative and the LazyColumn's * so — like the month space — every index is non-negative and the LazyColumn's
* item indices and week indices are the same number. * item indices and week indices are the same number.
* *
* Unlike month indices these *do* shift with the week-start preference, which is * Unlike month indices these *do* shift with the week-start preference, which is
* why the list state is keyed on it. * why the list state is keyed on it.
*/ */
private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1) private val WEEK_INDEX_EPOCH = GRID_FIRST_DAY
private val WEEK_INDEX_END = LocalDate(2100, 12, 31) private val WEEK_INDEX_END = GRID_LAST_DAY
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int { internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
val base = WEEK_INDEX_EPOCH.startOfWeek(weekStart) val base = WEEK_INDEX_EPOCH.startOfWeek(weekStart)
@@ -639,7 +704,7 @@ internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate = internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
WEEK_INDEX_EPOCH.startOfWeek(weekStart).plus(index * 7, DateTimeUnit.DAY) WEEK_INDEX_EPOCH.startOfWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
/** Total weeks the Dense grid scrolls through (1900 → 2100). */ /** Total weeks the Dense grid scrolls through. */
internal fun continuousWeekCount(weekStart: DayOfWeek): Int = internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
weekIndexOf(WEEK_INDEX_END, weekStart) + 1 weekIndexOf(WEEK_INDEX_END, weekStart) + 1
@@ -220,6 +220,7 @@ class OnboardingViewModel @Inject constructor(
prefs.defaultView, prefs.defaultView,
prefs.monthViewStyle, prefs.monthViewStyle,
prefs.weekStart, prefs.weekStart,
prefs.monthShowStartTimes,
::ViewChoice, ::ViewChoice,
).stateWhileSubscribed( ).stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
@@ -464,6 +465,7 @@ data class ViewChoice(
val defaultView: CalendarView, val defaultView: CalendarView,
val monthViewStyle: MonthViewStyle, val monthViewStyle: MonthViewStyle,
val weekStart: WeekStartPref, val weekStart: WeekStartPref,
val monthShowStartTimes: Boolean,
) )
/** /**
@@ -63,6 +63,7 @@ internal fun ViewStep(
CalendarView.Month -> MonthStylePreview( CalendarView.Month -> MonthStylePreview(
style = choice.monthViewStyle, style = choice.monthViewStyle,
weekStart = weekStart, weekStart = weekStart,
showStartTimes = choice.monthShowStartTimes,
height = PREVIEW_HEIGHT, height = PREVIEW_HEIGHT,
) )
CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT) CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT)
@@ -118,7 +119,12 @@ internal fun MonthStyleStep(
navigationIcon = navigationIcon, navigationIcon = navigationIcon,
) { ) {
StepPreview(selected = choice.monthViewStyle) { style -> StepPreview(selected = choice.monthViewStyle) { style ->
MonthStylePreview(style = style, weekStart = weekStart, height = PREVIEW_HEIGHT) MonthStylePreview(
style = style,
weekStart = weekStart,
showStartTimes = choice.monthShowStartTimes,
height = PREVIEW_HEIGHT,
)
} }
GroupedHint(stringResource(choice.monthViewStyle.descriptionRes), verticalPadding = 8.dp) GroupedHint(stringResource(choice.monthViewStyle.descriptionRes), verticalPadding = 8.dp)
options.forEachIndexed { index, style -> options.forEachIndexed { index, style ->
@@ -75,7 +75,7 @@ import de.jeanlucmakiola.calendula.domain.MatchSpan
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchHit import de.jeanlucmakiola.calendula.domain.SearchHit
import de.jeanlucmakiola.calendula.domain.SearchMonth import de.jeanlucmakiola.calendula.domain.SearchMonth
import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.fadeThrough
@@ -94,6 +94,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.domain.spanFirstDay import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
@@ -499,7 +500,7 @@ private fun SearchResultRow(
// Faded like a past event anywhere else in the app — search reaches back // Faded like a past event anywhere else in the app — search reaches back
// through the whole history. // through the whole history.
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier, modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
title = declinedTitle(marked(title, hit.titleSpans, highlight), event.isDeclined), title = declinedTitle(marked(title, hit.titleSpans, highlight), event.isCrossedOut),
summary = searchSummary(hit, highlight), summary = searchSummary(hit, highlight),
position = position, position = position,
minHeight = 64.dp, minHeight = 64.dp,
@@ -609,7 +610,7 @@ private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate()) .format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
} }
val use24Hour = LocalUse24HourFormat.current val use24Hour = LocalUse24HourFormat.current
val timeText = if (event.isAllDay) { val timeText = if (event.coversWholeDays(TimeZone.currentSystemDefault())) {
stringResource(R.string.event_detail_all_day) stringResource(R.string.event_detail_all_day)
} else { } else {
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start) remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
@@ -33,6 +33,7 @@ import kotlinx.datetime.DayOfWeek
internal fun MonthViewStylePicker( internal fun MonthViewStylePicker(
selected: MonthViewStyle, selected: MonthViewStyle,
weekStart: DayOfWeek, weekStart: DayOfWeek,
showStartTimes: Boolean,
onSelect: (MonthViewStyle) -> Unit, onSelect: (MonthViewStyle) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
@@ -50,6 +51,7 @@ internal fun MonthViewStylePicker(
MonthStylePreview( MonthStylePreview(
style = shown, style = shown,
weekStart = weekStart, weekStart = weekStart,
showStartTimes = showStartTimes,
height = PREVIEW_HEIGHT, height = PREVIEW_HEIGHT,
) )
} }
@@ -63,6 +63,7 @@ import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.LanguagePickerRow import de.jeanlucmakiola.floret.components.LanguagePickerRow
import de.jeanlucmakiola.calendula.widget.requestWidgetRedraw
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.crash.CrashReportDialog import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.CrashReporter
@@ -155,7 +156,7 @@ fun SettingsScreen(
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit, exit = overlayExit,
) { ) {
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets) WidgetsScreen(onBack = { section = null }, visible = section == SettingsSection.Widgets)
} }
AnimatedVisibility( AnimatedVisibility(
visible = section == SettingsSection.Licences, visible = section == SettingsSection.Licences,
@@ -258,15 +259,16 @@ private fun SettingsHub(
leading = { CategoryIcon(Icons.Default.Widgets, ChipAccent.Tertiary) }, leading = { CategoryIcon(Icons.Default.Widgets, ChipAccent.Tertiary) },
onClick = { onOpenSection(SettingsSection.Widgets) }, onClick = { onOpenSection(SettingsSection.Widgets) },
) )
val context = LocalContext.current
LanguagePickerRow( LanguagePickerRow(
position = Position.Middle, position = Position.Middle,
title = stringResource(R.string.settings_language), title = stringResource(R.string.settings_language),
autoLabel = stringResource(R.string.settings_language_auto), autoLabel = stringResource(R.string.settings_language_auto),
localesConfig = R.xml.locales_config, localesConfig = R.xml.locales_config,
predictiveBack = true, predictiveBack = true,
onChange = { requestWidgetRedraw(context) },
// Invite contributions right where users pick their language. // Invite contributions right where users pick their language.
header = { header = {
val context = LocalContext.current
val translateUrl = stringResource(R.string.about_translate_url) val translateUrl = stringResource(R.string.about_translate_url)
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_translate), title = stringResource(R.string.settings_translate),
@@ -16,7 +16,6 @@ import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.widget.WidgetSize
/** /**
* Settings screen state (M4). Persisted preferences are instant to read, so * Settings screen state (M4). Persisted preferences are instant to read, so
@@ -43,6 +42,8 @@ data class SettingsUiState(
val dimCompletedEvents: Boolean = false, val dimCompletedEvents: Boolean = false,
/** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */ /** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */
val showWeekNumbers: Boolean = false, val showWeekNumbers: Boolean = false,
/** Whether Month chips may show start times before their titles (#476). */
val monthShowStartTimes: Boolean = true,
/** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */ /** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */
val todayButtonInToolbar: Boolean = false, val todayButtonInToolbar: Boolean = false,
/** Whether events can be dragged to another slot to reschedule them (#68, #173). */ /** Whether events can be dragged to another slot to reschedule them (#68, #173). */
@@ -51,14 +52,10 @@ data class SettingsUiState(
val tapOpensEditor: Boolean = false, val tapOpensEditor: Boolean = false,
/** How far ahead the in-app Agenda screen shows events (v2.11). */ /** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: AgendaRange = AgendaRange.Month, val agendaScreenRange: AgendaRange = AgendaRange.Month,
/** How far ahead the agenda widget shows events (v2.11). */ /** Whether the agenda screen always anchors today at the top (#35); widgets have their own (#292). */
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
/** Whether the agenda (screen + widget) always anchors today at the top (#35). */
val agendaShowToday: Boolean = true, val agendaShowToday: Boolean = true,
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */ /** Whether the agenda shows its top range bar — header + switcher (v2.11). */
val agendaShowRangeBar: Boolean = true, val agendaShowRangeBar: Boolean = true,
/** The size step the agenda widget draws its text at (#51). */
val widgetSize: WidgetSize = WidgetSize.SMALL,
/** The calendar view the app opens on, and the home of the view back stack (M1). */ /** The calendar view the app opens on, and the home of the view back stack (M1). */
val defaultView: CalendarView = CalendarView.Week, val defaultView: CalendarView = CalendarView.Week,
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */ /** Which views the top-bar quick-switch button cycles through, and their order (#24). */
@@ -3,9 +3,6 @@ package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.appwidget.updateAll
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -42,20 +39,12 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SIZE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -67,8 +56,6 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
@@ -114,14 +101,13 @@ class SettingsViewModel @Inject constructor(
pastEventDisplay = this[SettingsKeys.pastEventDisplay], pastEventDisplay = this[SettingsKeys.pastEventDisplay],
dimCompletedEvents = this[SettingsKeys.dimCompletedEvents], dimCompletedEvents = this[SettingsKeys.dimCompletedEvents],
showWeekNumbers = this[SettingsKeys.showWeekNumbers], showWeekNumbers = this[SettingsKeys.showWeekNumbers],
monthShowStartTimes = this[SettingsKeys.monthShowStartTimes],
todayButtonInToolbar = this[SettingsKeys.todayButtonInToolbar], todayButtonInToolbar = this[SettingsKeys.todayButtonInToolbar],
dragToReschedule = this[SettingsKeys.dragToReschedule], dragToReschedule = this[SettingsKeys.dragToReschedule],
tapOpensEditor = this[SettingsKeys.tapOpensEditor], tapOpensEditor = this[SettingsKeys.tapOpensEditor],
agendaScreenRange = this[SettingsKeys.agendaScreenRange], agendaScreenRange = this[SettingsKeys.agendaScreenRange],
agendaWidgetRange = this[SettingsKeys.agendaWidgetRange],
agendaShowToday = this[SettingsKeys.agendaShowToday], agendaShowToday = this[SettingsKeys.agendaShowToday],
agendaShowRangeBar = this[SettingsKeys.agendaShowRangeBar], agendaShowRangeBar = this[SettingsKeys.agendaShowRangeBar],
widgetSize = this[SettingsKeys.widgetSize],
defaultView = this[SettingsKeys.defaultView], defaultView = this[SettingsKeys.defaultView],
quickSwitchConfig = this[SettingsKeys.quickSwitchConfig], quickSwitchConfig = this[SettingsKeys.quickSwitchConfig],
monthViewStyle = this[SettingsKeys.monthViewStyle], monthViewStyle = this[SettingsKeys.monthViewStyle],
@@ -181,10 +167,6 @@ class SettingsViewModel @Inject constructor(
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1) private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
// Serialises widget redraws: concurrent updateAll calls coalesce around a
// stale read and can leave the widget on an intermediate value.
private val widgetRefreshMutex = Mutex()
/** Contact special-dates sub-page (issue #15). */ /** Contact special-dates sub-page (issue #15). */
val specialDatesState: StateFlow<SpecialDatesUiState> = prefs.data.map { stored -> val specialDatesState: StateFlow<SpecialDatesUiState> = prefs.data.map { stored ->
val templates = stored.specialDatesTitleTemplates val templates = stored.specialDatesTitleTemplates
@@ -267,16 +249,7 @@ class SettingsViewModel @Inject constructor(
fun setDynamicColor(enabled: Boolean) = save { setDynamicColor(enabled) } fun setDynamicColor(enabled: Boolean) = save { setDynamicColor(enabled) }
fun setSoftenColors(enabled: Boolean) { fun setSoftenColors(enabled: Boolean) = save { setSoftenCalendarColors(enabled) }
viewModelScope.launch {
prefs.setSoftenCalendarColors(enabled)
// Both widgets paint event colours through the same softener.
widgetRefreshMutex.withLock {
AgendaWidget().updateAll(appContext)
MonthWidget().updateAll(appContext)
}
}
}
fun setFont(role: FontRole, token: String) = save { fun setFont(role: FontRole, token: String) = save {
when (role) { when (role) {
@@ -304,68 +277,13 @@ class SettingsViewModel @Inject constructor(
} }
} }
fun setWeekStart(pref: WeekStartPref) { fun setWeekStart(pref: WeekStartPref) = save { setWeekStart(pref) }
viewModelScope.launch {
prefs.setWeekStart(pref)
// Both widgets depend on the week start (month weekday header; the
// agenda widget's "this week" range).
widgetRefreshMutex.withLock {
AgendaWidget().updateAll(appContext)
MonthWidget().updateAll(appContext)
}
}
}
fun setAgendaScreenRange(range: AgendaRange) = save { setAgendaScreenRange(range) } fun setAgendaScreenRange(range: AgendaRange) = save { setAgendaScreenRange(range) }
fun setAgendaWidgetRange(range: AgendaRange) {
viewModelScope.launch {
prefs.setAgendaWidgetRange(range)
widgetRefreshMutex.withLock {
// Push into each instance's Glance state and recompose:
// updateAll alone won't re-run provideGlance's data preamble.
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setAgendaShowRangeBar(enabled: Boolean) = save { setAgendaShowRangeBar(enabled) } fun setAgendaShowRangeBar(enabled: Boolean) = save { setAgendaShowRangeBar(enabled) }
/** fun setAgendaShowToday(enabled: Boolean) = save { setAgendaShowToday(enabled) }
* Set the size the agenda widget draws its text at (#51), via the same
* Glance-state path as [setAgendaWidgetRange]. Agenda-only: the month widget
* sizes itself from the space it is given (#103).
*/
fun setWidgetSize(size: WidgetSize) {
viewModelScope.launch {
prefs.setWidgetSize(size)
widgetRefreshMutex.withLock {
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_SIZE_KEY] = size.name }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setAgendaShowToday(enabled: Boolean) {
viewModelScope.launch {
prefs.setAgendaShowToday(enabled)
// Same Glance-state path as setPastEventDisplay.
widgetRefreshMutex.withLock {
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_SHOW_TODAY_STATE_KEY] = enabled }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) } fun setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) }
@@ -375,6 +293,8 @@ class SettingsViewModel @Inject constructor(
fun setShowWeekNumbers(enabled: Boolean) = save { setShowWeekNumbers(enabled) } fun setShowWeekNumbers(enabled: Boolean) = save { setShowWeekNumbers(enabled) }
fun setMonthShowStartTimes(enabled: Boolean) = save { setMonthShowStartTimes(enabled) }
fun setTodayButtonInToolbar(enabled: Boolean) = save { setTodayButtonInToolbar(enabled) } fun setTodayButtonInToolbar(enabled: Boolean) = save { setTodayButtonInToolbar(enabled) }
fun setDragToReschedule(enabled: Boolean) = save { setDragToReschedule(enabled) } fun setDragToReschedule(enabled: Boolean) = save { setDragToReschedule(enabled) }
@@ -396,19 +316,7 @@ class SettingsViewModel @Inject constructor(
} }
} }
fun setPastEventDisplay(mode: PastEventDisplay) { fun setPastEventDisplay(mode: PastEventDisplay) = save { setPastEventDisplay(mode) }
viewModelScope.launch {
prefs.setPastEventDisplay(mode)
// Same Glance-state path as setAgendaWidgetRange.
widgetRefreshMutex.withLock {
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_PAST_DISPLAY_KEY] = mode.name }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setDimCompletedEvents(enabled: Boolean) = save { setDimCompletedEvents(enabled) } fun setDimCompletedEvents(enabled: Boolean) = save { setDimCompletedEvents(enabled) }
@@ -160,6 +160,13 @@ internal fun ViewsScreen(
checked = state.showWeekNumbers, checked = state.showWeekNumbers,
onCheckedChange = viewModel::setShowWeekNumbers, onCheckedChange = viewModel::setShowWeekNumbers,
summary = stringResource(R.string.settings_week_numbers_summary), summary = stringResource(R.string.settings_week_numbers_summary),
position = Position.Middle,
)
GroupedSwitchRow(
title = stringResource(R.string.settings_month_start_times),
checked = state.monthShowStartTimes,
onCheckedChange = viewModel::setMonthShowStartTimes,
summary = stringResource(R.string.settings_month_start_times_summary),
position = Position.Bottom, position = Position.Bottom,
) )
@@ -296,6 +303,7 @@ internal fun ViewsScreen(
selected = state.monthViewStyle, selected = state.monthViewStyle,
// The preview is a real grid, so it uses the real week start too. // The preview is a real grid, so it uses the real week start too.
weekStart = state.weekStart.resolveFirstDay(currentLocale()), weekStart = state.weekStart.resolveFirstDay(currentLocale()),
showStartTimes = state.monthShowStartTimes,
onSelect = viewModel::setMonthViewStyle, onSelect = viewModel::setMonthViewStyle,
onDismiss = { showMonthStyle = false }, onDismiss = { showMonthStyle = false },
) )
@@ -324,6 +332,7 @@ internal fun ViewsScreen(
selected = state.weekStart, selected = state.weekStart,
// The preview is a real grid, so it uses the user's own month style. // The preview is a real grid, so it uses the user's own month style.
monthStyle = state.monthViewStyle, monthStyle = state.monthViewStyle,
showStartTimes = state.monthShowStartTimes,
options = WEEK_START_OPTIONS, options = WEEK_START_OPTIONS,
onSelect = viewModel::setWeekStart, onSelect = viewModel::setWeekStart,
onDismiss = { showWeekStart = false }, onDismiss = { showWeekStart = false },
@@ -27,6 +27,7 @@ import de.jeanlucmakiola.floret.locale.currentLocale
internal fun WeekStartPicker( internal fun WeekStartPicker(
selected: WeekStartPref, selected: WeekStartPref,
monthStyle: MonthViewStyle, monthStyle: MonthViewStyle,
showStartTimes: Boolean,
options: List<WeekStartPref>, options: List<WeekStartPref>,
onSelect: (WeekStartPref) -> Unit, onSelect: (WeekStartPref) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
@@ -46,6 +47,7 @@ internal fun WeekStartPicker(
MonthStylePreview( MonthStylePreview(
style = monthStyle, style = monthStyle,
weekStart = day, weekStart = day,
showStartTimes = showStartTimes,
height = PREVIEW_HEIGHT, height = PREVIEW_HEIGHT,
) )
} }
@@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -18,56 +20,46 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.qs.NewEventTileService import de.jeanlucmakiola.calendula.qs.NewEventTileService
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedHint import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import kotlin.math.roundToInt import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.widget.config.PlacedWidget
import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity
import de.jeanlucmakiola.calendula.widget.WidgetKind
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import de.jeanlucmakiola.calendula.widget.config.backgroundLabel
import de.jeanlucmakiola.calendula.widget.config.placedWidgets
import de.jeanlucmakiola.calendula.widget.config.themeLabel
/** /**
* Widgets & tiles (#69): the home-screen widgets' settings and the Quick * Widgets & tiles (#69): the placed home-screen widgets, each opening its own
* Settings tile shortcut. The agenda widget keeps its own range and text size, * config screen (#292), and the Quick Settings tile shortcut.
* separate from the Agenda screen's range in [ViewsScreen].
*/ */
@Composable @Composable
internal fun WidgetsScreen( internal fun WidgetsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit, onBack: () -> Unit,
visible: Boolean = true, visible: Boolean = true,
) { ) {
var showAgendaWidgetRange by remember { mutableStateOf(false) }
var showWidgetSize by remember { mutableStateOf(false) }
CollapsingScaffold( CollapsingScaffold(
title = stringResource(R.string.settings_section_widgets), title = stringResource(R.string.settings_section_widgets),
onBack = onBack, onBack = onBack,
predictiveBack = true, predictiveBack = true,
visible = visible, visible = visible,
) { ) {
GroupedHint(stringResource(R.string.settings_widgets_hint)) GroupedHint(
Spacer(Modifier.height(8.dp)) stringResource(
// The launcher's "Edit widget" action only exists from Android 12.
GroupedRow( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.string.settings_widgets_hint
title = stringResource(R.string.settings_agenda_widget_range), else R.string.settings_widgets_hint_legacy,
summary = agendaRangeLabel(state.agendaWidgetRange), ),
position = Position.Top,
onClick = { showAgendaWidgetRange = true },
)
GroupedRow(
title = stringResource(R.string.settings_widget_size),
summary = widgetSizeLabel(state.widgetSize),
position = Position.Bottom,
onClick = { showWidgetSize = true },
) )
GroupedSectionHeader(stringResource(R.string.settings_widgets_placed))
PlacedWidgetRows()
// The add-tile prompt is API 33+; below that the tile is still // The add-tile prompt is API 33+; below that the tile is still
// addable from the QS editor, so the row just doesn't appear. // addable from the QS editor, so the row just doesn't appear.
@@ -76,30 +68,44 @@ internal fun WidgetsScreen(
QuickSettingsTileRow() QuickSettingsTileRow()
} }
} }
}
if (showAgendaWidgetRange) { @Composable
AgendaRangePicker( private fun PlacedWidgetRows() {
title = stringResource(R.string.settings_agenda_widget_range), val context = LocalContext.current
description = stringResource(R.string.settings_agenda_widget_range_hint), // Read on every resume, the first included, so a widget added, removed or
selected = state.agendaWidgetRange, // edited meanwhile shows up.
weekStart = state.weekStart.resolveFirstDay(currentLocale()), var refresh by remember { mutableIntStateOf(0) }
onSelect = viewModel::setAgendaWidgetRange, LifecycleResumeEffect(Unit) {
onDismiss = { showAgendaWidgetRange = false }, refresh++
) onPauseOrDispose {}
} }
if (showWidgetSize) { val widgets by produceState<List<PlacedWidget>?>(initialValue = null, refresh) {
OptionPicker( if (refresh > 0) value = withContext(Dispatchers.IO) { context.placedWidgets() }
title = stringResource(R.string.settings_widget_size), }
header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) }, val placed = widgets ?: return
predictiveBack = true, if (placed.isEmpty()) {
options = WidgetSize.entries, GroupedRow(
selected = state.widgetSize, title = stringResource(R.string.settings_widgets_none),
label = { widgetSizeLabel(it) }, position = Position.Alone,
// No live preview: the widget is Glance/RemoteViews, so a Compose dimmed = true,
// mock-up would be a second implementation free to drift. )
summary = { widgetSizeSummary(it) }, return
onSelect = viewModel::setWidgetSize, }
onDismiss = { showWidgetSize = false }, val kindCounts = placed.groupingBy { it.kind }.eachCount()
val kindSeen = mutableMapOf<WidgetKind, Int>()
placed.forEachIndexed { index, widget ->
val number = kindSeen.merge(widget.kind, 1, Int::plus)!!
val label = stringResource(widget.kind.label)
GroupedRow(
title = if (kindCounts.getValue(widget.kind) > 1) stringResource(R.string.widget_numbered, label, number) else label,
summary = stringResource(
R.string.widget_appearance_summary,
backgroundLabel(widget.appearance.background),
themeLabel(widget.appearance.theme),
),
position = positionOf(index, placed.size),
onClick = { context.startActivity(WidgetConfigActivity.intent(context, widget.appWidgetId)) },
) )
} }
} }
@@ -131,24 +137,3 @@ private fun requestAddQsTile(context: Context) {
context.mainExecutor, context.mainExecutor,
) { /* result code unused — the system surfaces its own feedback */ } ) { /* result code unused — the system surfaces its own feedback */ }
} }
/**
* What a size step does, as a percentage of the smallest one's event text.
* Derived from the widget's own [metricsFor] table rather than hardcoded.
*/
@Composable
private fun widgetSizeSummary(size: WidgetSize): String {
val base = metricsFor(WidgetSize.SMALL).eventTitle.value
val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
return stringResource(R.string.settings_widget_size_summary, percent)
}
@Composable
private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
when (size) {
WidgetSize.SMALL -> R.string.settings_widget_size_small
WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
WidgetSize.LARGE -> R.string.settings_widget_size_large
WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
},
)
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.StripDrop
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.TimelineResizeDrop import de.jeanlucmakiola.calendula.ui.common.TimelineResizeDrop
@@ -25,6 +26,7 @@ internal class TimelineActions(
val onCreateAt: (LocalDate, Int) -> Unit, val onCreateAt: (LocalDate, Int) -> Unit,
val onDrop: (TimelineDrop) -> Unit, val onDrop: (TimelineDrop) -> Unit,
val onResize: (TimelineResizeDrop) -> Unit, val onResize: (TimelineResizeDrop) -> Unit,
val onStripDrop: (StripDrop) -> Unit,
) )
/** The callbacks are read through their latest values, so the holder outlives them. */ /** The callbacks are read through their latest values, so the holder outlives them. */
@@ -37,12 +39,14 @@ internal fun rememberTimelineActions(
onCreateAt: (LocalDate, Int) -> Unit, onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit, onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit, onResize: (TimelineResizeDrop) -> Unit,
onStripDrop: (StripDrop) -> Unit,
): TimelineActions { ): TimelineActions {
val eventClick = rememberUpdatedState(onEventClick) val eventClick = rememberUpdatedState(onEventClick)
val openDay = rememberUpdatedState(onOpenDay) val openDay = rememberUpdatedState(onOpenDay)
val createAt = rememberUpdatedState(onCreateAt) val createAt = rememberUpdatedState(onCreateAt)
val drop = rememberUpdatedState(onDrop) val drop = rememberUpdatedState(onDrop)
val resize = rememberUpdatedState(onResize) val resize = rememberUpdatedState(onResize)
val stripDrop = rememberUpdatedState(onStripDrop)
return remember(scrollState, dragController) { return remember(scrollState, dragController) {
TimelineActions( TimelineActions(
scrollState = scrollState, scrollState = scrollState,
@@ -52,6 +56,7 @@ internal fun rememberTimelineActions(
onCreateAt = { date, minutes -> createAt.value(date, minutes) }, onCreateAt = { date, minutes -> createAt.value(date, minutes) },
onDrop = { drop.value(it) }, onDrop = { drop.value(it) },
onResize = { resize.value(it) }, onResize = { resize.value(it) },
onStripDrop = { stripDrop.value(it) },
) )
} }
} }
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.week package de.jeanlucmakiola.calendula.ui.week
import de.jeanlucmakiola.calendula.ui.common.CalendarSpan
import de.jeanlucmakiola.calendula.ui.common.startOfWeek import de.jeanlucmakiola.calendula.ui.common.startOfWeek
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -7,16 +8,7 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus import kotlinx.datetime.plus
/** Pages the week pager spans — a little under four centuries. */ /** A fixed day every grid's pages are counted from. */
internal const val WEEK_PAGE_COUNT: Int = 20_000
/** Pages the multi-day pager spans — over three centuries at the widest page. */
internal const val DAYS_PAGE_COUNT: Int = 40_000
/** Pages the day pager spans — a little under four centuries. */
internal const val DAY_PAGE_COUNT: Int = 140_000
/** A fixed day every grid's pages are counted from, at its middle page. */
private val DAYS_EPOCH = LocalDate(2000, 1, 1) private val DAYS_EPOCH = LocalDate(2000, 1, 1)
/** The week the week pager's middle page shows; any date works, it only has to stay fixed. */ /** The week the week pager's middle page shows; any date works, it only has to stay fixed. */
@@ -24,13 +16,19 @@ private val WEEK_EPOCH = LocalDate(2000, 1, 3)
/** /**
* How a timeline pager cuts the calendar: runs of [dayCount] days, offset * How a timeline pager cuts the calendar: runs of [dayCount] days, offset
* [phase] days from a fixed epoch, over [pageCount] pages centred on it. The * [phase] days from a fixed epoch, over as many pages as cover [CalendarSpan].
* multi-day view's phase (#193) stays put while paging and only changes when a * The multi-day view's phase (#193) stays put while paging and only changes when
* jump moves another date to the first column. * a jump moves another date to the first column.
*/ */
data class DaysGrid(val dayCount: Int, val phase: Int, val pageCount: Int = DAYS_PAGE_COUNT) { data class DaysGrid(val dayCount: Int, val phase: Int) {
private val epochPage: Int get() = pageCount / 2 /** The page showing the epoch; page 0 is the one holding [CalendarSpan.FIRST_DAY]. */
private val epochPage: Int get() = -pagesFromEpoch(CalendarSpan.FIRST_DAY)
val pageCount: Int get() = epochPage + pagesFromEpoch(CalendarSpan.LAST_DAY) + 1
private fun pagesFromEpoch(date: LocalDate): Int =
Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount)
/** First day shown on pager [page]. */ /** First day shown on pager [page]. */
fun startForPage(page: Int): LocalDate = fun startForPage(page: Int): LocalDate =
@@ -38,18 +36,16 @@ data class DaysGrid(val dayCount: Int, val phase: Int, val pageCount: Int = DAYS
/** The pager page showing [date]. */ /** The pager page showing [date]. */
fun pageFor(date: LocalDate): Int = fun pageFor(date: LocalDate): Int =
(epochPage + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount)) (epochPage + pagesFromEpoch(date)).coerceIn(0, pageCount - 1)
.coerceIn(0, pageCount - 1)
companion object { companion object {
/** Single days: every date starts a page of its own. */ /** Single days: every date starts a page of its own. */
val days: DaysGrid = DaysGrid(dayCount = 1, phase = 0, pageCount = DAY_PAGE_COUNT) val days: DaysGrid = DaysGrid(dayCount = 1, phase = 0)
/** Whole weeks, starting on [firstDay]. */ /** Whole weeks, starting on [firstDay]. */
fun weeks(firstDay: DayOfWeek): DaysGrid = DaysGrid( fun weeks(firstDay: DayOfWeek): DaysGrid = DaysGrid(
dayCount = 7, dayCount = 7,
phase = DAYS_EPOCH.daysUntil(WEEK_EPOCH.startOfWeek(firstDay)), phase = DAYS_EPOCH.daysUntil(WEEK_EPOCH.startOfWeek(firstDay)),
pageCount = WEEK_PAGE_COUNT,
) )
/** The grid of [dayCount]-day pages on which [date] is a page's first day. */ /** The grid of [dayCount]-day pages on which [date] is a page's first day. */
@@ -106,9 +106,16 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.runtime.State
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.unit.Density
import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.layout
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -125,6 +132,9 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.dateZone
import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.domain.spanLastDay
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
import de.jeanlucmakiola.calendula.ui.common.eventPaint import de.jeanlucmakiola.calendula.ui.common.eventPaint
@@ -147,10 +157,12 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.MoveRequest import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.MoveTarget import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.ghostsDerived
import de.jeanlucmakiola.calendula.ui.common.StripDrag
import de.jeanlucmakiola.calendula.ui.common.StripDrop
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
import de.jeanlucmakiola.calendula.ui.common.continuesBefore import de.jeanlucmakiola.calendula.ui.common.continuesBefore
import de.jeanlucmakiola.calendula.ui.common.ChipCuts import de.jeanlucmakiola.calendula.ui.common.ChipCuts
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
@@ -185,8 +197,10 @@ import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION import de.jeanlucmakiola.calendula.ui.common.renderedSpan
import de.jeanlucmakiola.calendula.ui.common.hourHeight import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.dayFitHeight
import de.jeanlucmakiola.calendula.ui.common.FabClearance
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
@@ -196,8 +210,8 @@ import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
import de.jeanlucmakiola.calendula.ui.common.hourGridCells import de.jeanlucmakiola.calendula.ui.common.hourGridCells
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus import kotlinx.datetime.plus
@@ -207,6 +221,7 @@ import kotlinx.datetime.toLocalDateTime
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
import kotlinx.datetime.yearMonth import kotlinx.datetime.yearMonth
import de.jeanlucmakiola.calendula.ui.common.rememberRowWeekNumber
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */ /** One lane of the all-day strip, sized to a trimmed bar line (#190). */
private val ALL_DAY_ROW_HEIGHT = 20.dp private val ALL_DAY_ROW_HEIGHT = 20.dp
@@ -296,14 +311,6 @@ internal enum class TimelineKind(
// A run of days often straddles two weeks, so no single number fits it. // A run of days often straddles two weeks, so no single number fits it.
MultiDay(showWeekNumber = false, todayText = R.string.week_today_action, headerBias = 0f), MultiDay(showWeekNumber = false, todayText = R.string.week_today_action, headerBias = 0f),
Day(showWeekNumber = false, todayText = R.string.day_today_action, headerBias = -1f), Day(showWeekNumber = false, todayText = R.string.day_today_action, headerBias = -1f),
;
fun title(start: LocalDate, end: LocalDate, locale: Locale, currentYear: Int, abbreviated: Boolean): String =
if (this == Day) {
formatDayTitle(start, locale, currentYear, abbreviated)
} else {
formatWeekTitle(start, end, locale, currentYear, abbreviated)
}
} }
internal val CalendarView.timelineKind: TimelineKind internal val CalendarView.timelineKind: TimelineKind
@@ -478,9 +485,10 @@ private fun PagedTimelineScreen(
val jumpToToday = { jumpToDate(today) } val jumpToToday = { jumpToDate(today) }
val locale = currentLocale() val locale = currentLocale()
val (title, shortTitle) = remember(titleKind, weekStart, weekEnd, locale, today.year) { // The day view names the month too: its header already shows weekday and date (#501).
titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = false) to val (title, shortTitle) = remember(weekStart, weekEnd, locale, today.year) {
titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = true) formatTimelineTitle(weekStart, weekEnd, locale, today.year, abbreviated = false) to
formatTimelineTitle(weekStart, weekEnd, locale, today.year, abbreviated = true)
} }
PublishChrome( PublishChrome(
ScreenChrome( ScreenChrome(
@@ -577,7 +585,9 @@ private fun WeekContent(
eventId = drop.event.eventId, eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(), beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(), endMillis = drop.event.end.toEpochMilliseconds(),
isAllDay = drop.event.isAllDay,
target = MoveTarget.Start(start), target = MoveTarget.Start(start),
token = drop.token,
), ),
) )
} }
@@ -596,7 +606,26 @@ private fun WeekContent(
eventId = drop.event.eventId, eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(), beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(), endMillis = drop.event.end.toEpochMilliseconds(),
isAllDay = drop.event.isAllDay,
target = MoveTarget.Span(drop.start, drop.end), target = MoveTarget.Span(drop.start, drop.end),
token = drop.token,
),
)
}
// Off the all-day strip: sideways moves days, into the grid makes it timed.
val onStripDrop: (StripDrop) -> Unit = { drop ->
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
isAllDay = drop.event.isAllDay,
target = when (drop) {
is StripDrop.Days -> MoveTarget.ByDays(drop.days)
is StripDrop.Timed -> MoveTarget.Span(drop.start, drop.end)
},
token = drop.token,
), ),
) )
} }
@@ -661,6 +690,7 @@ private fun WeekContent(
onCreateAt = onCreateAt, onCreateAt = onCreateAt,
onDrop = onDrop, onDrop = onDrop,
onResize = onResize, onResize = onResize,
onStripDrop = onStripDrop,
) )
Box(modifier = modifier) { Box(modifier = modifier) {
@@ -736,7 +766,11 @@ private fun WeekPager(
val loaded = pageState as? WeekUiState.Success val loaded = pageState as? WeekUiState.Success
// Until its days arrive a page shows their frame without events. // Until its days arrive a page shows their frame without events.
val empty = remember(pageSpan, today) { emptySpan(pageSpan, today) } val empty = remember(pageSpan, today) { emptySpan(pageSpan, today) }
val stripHeight = loaded?.allDayStripHeight() // A bar held over other days in the strip makes room there before it is dropped.
val held = dragController.stripPreviews().takeIf { page == pagerState.currentPage }.orEmpty()
val zone = LocalTimelineZone.current
val shown = remember(loaded, held, zone) { loaded?.withStripPreview(held, zone) }
val stripHeight = shown?.allDayStripHeight()
DisposableEffect(page, stripHeight) { DisposableEffect(page, stripHeight) {
if (stripHeight != null) stripHeights[page] = stripHeight if (stripHeight != null) stripHeights[page] = stripHeight
onDispose { stripHeights.remove(page) } onDispose { stripHeights.remove(page) }
@@ -744,8 +778,8 @@ private fun WeekPager(
ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) { ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) {
WeekPage( WeekPage(
// A cached page may predate midnight; today comes off the clock (#373). // A cached page may predate midnight; today comes off the clock (#373).
state = remember(loaded, empty, today) { state = remember(shown, empty, today) {
loaded?.takeIf { it.today != today }?.copy(today = today) ?: loaded ?: empty shown?.takeIf { it.today != today }?.copy(today = today) ?: shown ?: empty
}, },
headerBias = kind.headerBias, headerBias = kind.headerBias,
allDayHeight = allDayHeight, allDayHeight = allDayHeight,
@@ -757,6 +791,43 @@ private fun WeekPager(
} }
} }
/**
* [this] with the strip bars [held] sideways laid out at the days they are over,
* so the bars there move aside live. Each bar keeps its own event, and with it
* its key and the gesture on it; one moved off the page keeps a lane it is not
* seen in.
*/
internal fun WeekUiState.Success.withStripPreview(held: List<StripDrag>, zone: TimeZone): WeekUiState.Success {
val shifts = held.filter { !it.overGrid && it.dayShift != 0 }.associate { it.event.instanceId to it.dayShift }
if (shifts.isEmpty()) return this
val originals = allDaySpans.filter { it.event.instanceId in shifts }.associateBy { it.event.instanceId }
if (originals.isEmpty()) return this
val events = allDaySpans.map { span ->
shifts[span.event.instanceId]?.let { span.event.shiftedDays(it, zone) } ?: span.event
}
val spans = layoutAllDay(events, days, zone).map { span ->
originals[span.event.instanceId]?.let { span.copy(event = it.event) } ?: span
}
val laidOut = spans.mapTo(HashSet()) { it.event.instanceId }
return copy(allDaySpans = spans + originals.values.filter { it.event.instanceId !in laidOut }.map { it.copy(lane = 0) })
}
private fun EventInstance.shiftedDays(days: Int, zone: TimeZone): EventInstance {
val dates = dateZone(zone)
return copy(start = start.plus(days, DateTimeUnit.DAY, dates), end = end.plus(days, DateTimeUnit.DAY, dates))
}
/** Whether these bars draw the strip drop [drop] at the days it was dropped on. */
private fun List<PlacedBar>.drawStripDrop(drop: StripDrag, zone: TimeZone): Boolean {
val target = drop.event.spanFirstDay(zone).plus(drop.dayShift, DateTimeUnit.DAY)
return any { bar ->
bar.event.instanceId != drop.event.instanceId &&
(bar.event.eventId == drop.event.eventId ||
(drop.event.title.isNotBlank() && bar.event.title == drop.event.title)) &&
bar.event.spanFirstDay(zone) == target
}
}
/** A page with no events yet, for one whose days are still loading. */ /** A page with no events yet, for one whose days are still loading. */
private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success { private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success {
val days = (0 until span.count).map { span.start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) } val days = (0 until span.count).map { span.start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) }
@@ -892,8 +963,9 @@ private fun WeekFrame(
), ),
) { ) {
val zoom = LocalTimelineZoom.current val zoom = LocalTimelineZoom.current
val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight val fitHeight = dayFitHeight(
else maxHeight if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight,
)
val hourHeight = zoom.scale.hourHeight(fitHeight) val hourHeight = zoom.scale.hourHeight(fitHeight)
HourGutter( HourGutter(
scrollState = actions.scrollState, scrollState = actions.scrollState,
@@ -992,7 +1064,7 @@ private fun TimelinePageContent(
LocalChipGround provides MaterialTheme.colorScheme.surface, LocalChipGround provides MaterialTheme.colorScheme.surface,
) { ) {
AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight, AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight,
animatedHeight = animatedAllDayHeight, onEventClick = actions.onEventClick) animatedHeight = animatedAllDayHeight, actions = actions, active = active)
} }
} }
Spacer(Modifier.height(TIMELINE_TOP_GAP)) Spacer(Modifier.height(TIMELINE_TOP_GAP))
@@ -1082,7 +1154,7 @@ private fun WeekDayHeader(
*/ */
@Composable @Composable
private fun WeekNumberCell(weekStart: LocalDate, showBadge: Boolean, modifier: Modifier = Modifier) { private fun WeekNumberCell(weekStart: LocalDate, showBadge: Boolean, modifier: Modifier = Modifier) {
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() } val weekNumber = rememberRowWeekNumber(weekStart)
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@@ -1127,9 +1199,23 @@ private fun AllDayStrip(
bars: List<PlacedBar>, bars: List<PlacedBar>,
height: Dp, height: Dp,
animatedHeight: (() -> Dp)? = null, animatedHeight: (() -> Dp)? = null,
onEventClick: (EventInstance) -> Unit, actions: TimelineActions,
active: Boolean,
) { ) {
val dark = LocalFloretDarkTheme.current val dark = LocalFloretDarkTheme.current
val zone = LocalTimelineZone.current
val dragController = actions.dragController
// Only the page the finger is on draws the drag; the controller is shared.
val held = (dragController.stripDrag ?: dragController.stripSettling)?.takeIf { active }
val sideways = held?.takeIf { !it.overGrid }
val lifted = dragController.liftedInstanceId?.takeIf { active && dragController.stripDrag?.overGrid != false }
val settling = dragController.stripSettling?.takeIf { active }
val heldStrips = dragController.heldStrips.takeIf { active }.orEmpty()
// Seated once the re-read draws the event at the days it was dropped on.
LaunchedEffect(settling, heldStrips, bars) {
if (settling != null && bars.drawStripDrop(settling, zone)) dragController.seatStrip()
heldStrips.forEach { if (!it.seated && bars.drawStripDrop(it.drag, zone)) dragController.seatHeldStrip(it.token) }
}
Row( Row(
modifier = Modifier modifier = Modifier
@@ -1150,12 +1236,20 @@ private fun AllDayStrip(
content = { content = {
bars.forEach { bar -> bars.forEach { bar ->
key(bar.event.eventId, bar.event.start) { key(bar.event.eventId, bar.event.start) {
val moving = sideways?.event?.instanceId == bar.event.instanceId
val lane = animatedStripLane(bar.toLane)
AllDayBar( AllDayBar(
event = bar.event, event = bar.event,
lastDay = bar.toRange.endInclusive, lastDay = bar.toRange.endInclusive,
dark = dark, dark = dark,
onClick = { onEventClick(bar.event) }, dragController = dragController,
lifted = moving,
ghosted = lifted == bar.event.instanceId,
onClick = { actions.onEventClick(bar.event) },
onDrop = actions.onStripDrop,
modifier = Modifier modifier = Modifier
.then(if (moving) Modifier.zIndex(1f) else Modifier)
.then(StripLane(lane))
.morphEvent(ViewMorphKey.event(bar.event, bar.keyDate)) .morphEvent(ViewMorphKey.event(bar.event, bar.keyDate))
.padding(horizontal = 1.dp, vertical = 1.dp), .padding(horizontal = 1.dp, vertical = 1.dp),
) )
@@ -1172,10 +1266,19 @@ private fun AllDayStrip(
val progress = geometry.progress() val progress = geometry.progress()
val placed = measurables.mapIndexed { i, measurable -> val placed = measurables.mapIndexed { i, measurable ->
val bar = bars[i] val bar = bars[i]
val extent = geometry.extent(bar.fromRange, bar.toRange, width, 0f) val shift = sideways?.takeIf { it.event.instanceId == bar.event.instanceId }?.dayShift ?: 0
val extent = if (shift == 0) {
geometry.extent(bar.fromRange, bar.toRange, width, 0f)
} else {
// The whole event, not the part this page shows: it may come in from off screen.
val moved = bar.event.spanFirstDay(zone)..bar.event.spanLastDay(zone)
geometry.extent(moved.shiftedBy(shift), moved.shiftedBy(shift), width, 0f)
}
val left = extent.left.roundToInt() val left = extent.left.roundToInt()
val barWidth = (extent.right.roundToInt() - left).coerceAtLeast(0) val barWidth = (extent.right.roundToInt() - left).coerceAtLeast(0)
val y = (lerp(bar.fromLane.toFloat(), bar.toLane.toFloat(), progress) * rowPx).roundToInt() // Standing still a bar tweens to a new lane, as bars make room for a dragged one.
val lane = (measurable.parentData as? StripLane)?.lane?.takeIf { bar.fromLane == bar.toLane }
val y = ((lane?.value ?: lerp(bar.fromLane.toFloat(), bar.toLane.toFloat(), progress)) * rowPx).roundToInt()
Triple(measurable.measure(Constraints.fixed(barWidth, rowPx.roundToInt())), left, y) Triple(measurable.measure(Constraints.fixed(barWidth, rowPx.roundToInt())), left, y)
} }
layout(constraints.maxWidth, constraints.maxHeight) { layout(constraints.maxWidth, constraints.maxHeight) {
@@ -1185,12 +1288,31 @@ private fun AllDayStrip(
} }
} }
private data class StripLane(val lane: State<Float>) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any = this@StripLane
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun animatedStripLane(lane: Int): State<Float> {
val spec: FiniteAnimationSpec<Float> =
if (rememberReduceMotion()) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
return animateFloatAsState(lane.toFloat(), spec, label = "strip-lane")
}
private fun ClosedRange<LocalDate>.shiftedBy(days: Int): ClosedRange<LocalDate> =
start.plus(days, DateTimeUnit.DAY)..endInclusive.plus(days, DateTimeUnit.DAY)
@Composable @Composable
private fun AllDayBar( private fun AllDayBar(
event: EventInstance, event: EventInstance,
lastDay: LocalDate, lastDay: LocalDate,
dark: Boolean, dark: Boolean,
dragController: TimelineDragController,
lifted: Boolean,
ghosted: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onDrop: (StripDrop) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
@@ -1198,8 +1320,29 @@ private fun AllDayBar(
// A bar is one piece, so it dims once the last day it covers here has passed. // A bar is one piece, so it dims once the last day it covers here has passed.
val dimmed = dimCutoff != null && event.isPastOn(lastDay, dimCutoff) val dimmed = dimCutoff != null && event.isPastOn(lastDay, dimCutoff)
val paint = eventPaint(event, dark) val paint = eventPaint(event, dark)
val zone = LocalTimelineZone.current
val draggable = eventDragAllowed(event)
// Long press lifts the bar: sideways moves its days, down into the grid makes it timed.
val dragModifier = rememberEventDragSource(
enabled = draggable,
key = event.instanceId,
onPickUp = { pointer, _, _ ->
dragController.beginStrip(event, zone, pointer)
if (dragController.stripDrag != null) PickUp.Drag else PickUp.None
},
onMove = dragController::move,
onDrop = { dragController.finishStrip()?.let(onDrop) },
onCancel = dragController::cancel,
onRelease = dragController::cancel,
)
// Ghosted while it hangs over the grid as a timed block, and until that drop lands.
val ghost = ghostAlpha(ghosted)
val raise = selectionLift(lifted)
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.alpha(ghost)
.selectionLift(raise, EventChipShape)
.then(dragModifier)
.eventSurface(paint, EventChipShape) .eventSurface(paint, EventChipShape)
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp) .padding(horizontal = 6.dp, vertical = 2.dp)
@@ -1254,7 +1397,9 @@ private fun Timeline(
else Modifier.stableMorphViewport(animatedAllDayHeight, fullViewportHeight), else Modifier.stableMorphViewport(animatedAllDayHeight, fullViewportHeight),
), ),
) { ) {
val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight val fitHeight = dayFitHeight(
if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight,
)
val hourHeight = zoom.scale.hourHeight(fitHeight) val hourHeight = zoom.scale.hourHeight(fitHeight)
val totalHeight = hourHeight * 24 val totalHeight = hourHeight * 24
// The pinch sits above the scroll viewport: it has to outrank the // The pinch sits above the scroll viewport: it has to outrank the
@@ -1287,9 +1432,11 @@ private fun Timeline(
.fillMaxSize() .fillMaxSize()
.then(pinch) .then(pinch)
.padding(end = TIMELINE_CONTENT_END_INSET) .padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(TIMELINE_CORNER))
.verticalScroll(scrollState) .verticalScroll(scrollState)
.onGloballyPositioned { if (active) dragController.geometry.viewport = it }, .onGloballyPositioned { if (active) dragController.geometry.viewport = it }
// Past midnight, outside the columns: nothing there takes a tap or a drop.
.padding(bottom = FabClearance),
) { ) {
TimelineColumns( TimelineColumns(
geometry = geometry, geometry = geometry,
@@ -1325,6 +1472,7 @@ private fun Timeline(
visibleTop = visibleTop, visibleTop = visibleTop,
viewportHeight = visibleViewport, viewportHeight = visibleViewport,
actions = actions, actions = actions,
shape = columnShape(day == geometry.dates.first(), day == geometry.dates.last()),
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight(),
) )
} }
@@ -1332,6 +1480,19 @@ private fun Timeline(
} }
} }
/**
* The outer columns round their bottom corner at midnight: the room left below
* for the buttons means the rounded window no longer does it there.
*/
private fun columnShape(first: Boolean, last: Boolean): Shape =
if (!first && !last) RectangleShape
else RoundedCornerShape(
bottomStart = if (first) TIMELINE_CORNER else 0.dp,
bottomEnd = if (last) TIMELINE_CORNER else 0.dp,
)
private val TIMELINE_CORNER = 16.dp
@Composable @Composable
private fun DayColumnCard( private fun DayColumnCard(
blocks: List<TimedBlock>, blocks: List<TimedBlock>,
@@ -1343,6 +1504,7 @@ private fun DayColumnCard(
visibleTop: () -> Float, visibleTop: () -> Float,
viewportHeight: () -> Float, viewportHeight: () -> Float,
actions: TimelineActions, actions: TimelineActions,
shape: Shape = RectangleShape,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scrollState = actions.scrollState val scrollState = actions.scrollState
@@ -1373,8 +1535,8 @@ private fun DayColumnCard(
val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, actions.onDrop) val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, actions.onDrop)
Card( Card(
// Plain rectangular columns — the soft corners come from the outer // Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges. // rounded scroll viewport, apart from the outer ones' bottoms ([columnShape]).
shape = RectangleShape, shape = shape,
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
// With the grid on, the container colour moves onto the hour cells // With the grid on, the container colour moves onto the hour cells
// and the column behind them recedes to surface, so the gap between // and the column behind them recedes to surface, so the gap between
@@ -1414,7 +1576,6 @@ private fun DayColumnCard(
// block or handle on top can claim the press first. // block or handle on top can claim the press first.
.then(draftSurface), .then(draftSurface),
) { ) {
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
// Keyed by event, so a block that changes time or lane is the *same* // Keyed by event, so a block that changes time or lane is the *same*
// composable afterwards and tweens there. The ordinal disambiguates // composable afterwards and tweens there. The ordinal disambiguates
// a column holding two occurrences of one series. // a column holding two occurrences of one series.
@@ -1422,24 +1583,24 @@ private fun DayColumnCard(
shown.forEach { block -> shown.forEach { block ->
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1 val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
key(block.event.eventId, ordinal) { key(block.event.eventId, ordinal) {
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(
xFraction = block.lane.toFloat() / block.laneCount,
widthFraction = 1f / block.laneCount,
y = top,
height = height,
landing = dragController.ghosts(block),
)
// Where this block is cut at midnight, which decides both // Where this block is cut at midnight, which decides both
// its shape and the half-gap it gives up to the hour cells. // its shape and the half-gap it gives up to the hour cells.
val cuts = remember(block, date, zone) { val cuts = remember(block, date, zone) {
timedBlockCuts( timedBlockCuts(
block.continuesBefore(date, zone), block.continuesBefore(date, zone),
block.continuesAfter(date, zone), block.continuesAfter,
) )
} }
val span = remember(block) { block.renderedSpan() }
val top = hourHeight * (span.topMin / 60f)
val height = hourHeight * ((span.bottomMin - span.topMin) / 60f)
val place = animatedBlockPlacement(
xFraction = block.lane.toFloat() / block.laneCount,
widthFraction = 1f / block.laneCount,
y = top,
height = height,
landing = dragController.ghostsDerived(block),
)
val topInset = hourCellBlockInset(showHourGrid, cuts.top) val topInset = hourCellBlockInset(showHourGrid, cuts.top)
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom) val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
val blockHeight = (place.height - topInset - bottomInset) val blockHeight = (place.height - topInset - bottomInset)
@@ -1606,7 +1767,7 @@ private fun EventBlock(
onCancel = dragController::cancel, onCancel = dragController::cancel,
onRelease = { dragController.select(block.event, fromLift = draggable) }, onRelease = { dragController.select(block.event, fromLift = draggable) },
) )
val lifted = draggable && dragController.ghosts(block) val lifted = draggable && dragController.ghostsDerived(block)
// Released in place, the block takes over the copy's lift without a dip. // Released in place, the block takes over the copy's lift without a dip.
val takesOverLift = selected && dragController.selection?.fromLift == true val takesOverLift = selected && dragController.selection?.fromLift == true
val ghostFade = ghostAlpha(lifted) val ghostFade = ghostAlpha(lifted)
@@ -1775,7 +1936,7 @@ private fun rememberDraftSurface(
block = block, block = block,
zone = zone, zone = zone,
pointerInRoot = pointer, pointerInRoot = pointer,
blockInRoot = node + Offset(0f, startMin / 60f * hour), blockInRoot = node + Offset(0f, block.renderedSpan().topMin / 60f * hour),
) )
true true
}, },
@@ -1823,8 +1984,10 @@ private fun WeekLoading(dayCount: Int) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Same scale resolution as the loaded timeline, so the skeleton's // Same scale resolution as the loaded timeline, so the skeleton's
// columns don't resize the moment the real week arrives. // columns don't resize the moment the real week arrives.
val fitHeight = maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 - val fitHeight = dayFitHeight(
headerHeight - TIMELINE_TOP_GAP maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 -
headerHeight - TIMELINE_TOP_GAP,
)
val hourHeight = scale.hourHeight(fitHeight) val hourHeight = scale.hourHeight(fitHeight)
val totalHeight = hourHeight * 24 val totalHeight = hourHeight * 24
// The skeleton wears the loaded columns' own ground, so the arrival // The skeleton wears the loaded columns' own ground, so the arrival
@@ -1837,7 +2000,8 @@ private fun WeekLoading(dayCount: Int) {
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(end = TIMELINE_CONTENT_END_INSET) .padding(end = TIMELINE_CONTENT_END_INSET)
.verticalScroll(scrollState), .verticalScroll(scrollState)
.padding(bottom = FabClearance),
) { ) {
Spacer(Modifier.width(GUTTER_WIDTH)) Spacer(Modifier.width(GUTTER_WIDTH))
repeat(dayCount) { repeat(dayCount) {
@@ -1863,7 +2027,7 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
formatMinuteOfDay(min, is24Hour, locale) formatMinuteOfDay(min, is24Hour, locale)
/** /**
* The week's title: just the month [weekStart] falls in. * Every timeline's title, the day view's included: just the month [weekStart] falls in.
* *
* The day numbers are already in the column headers directly below, so spelling * The day numbers are already in the column headers directly below, so spelling
* out "13.–19. Jul" restates them in the widest string in the bar. Naming the * out "13.–19. Jul" restates them in the widest string in the bar. Naming the
@@ -1878,7 +2042,7 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
* "December" with no year would be actively misleading while the reader is * "December" with no year would be actively misleading while the reader is
* looking straight at January dates. * looking straight at January dates.
*/ */
private fun formatWeekTitle( private fun formatTimelineTitle(
weekStart: LocalDate, weekStart: LocalDate,
weekEnd: LocalDate, weekEnd: LocalDate,
locale: Locale, locale: Locale,
@@ -1893,17 +2057,3 @@ private fun formatWeekTitle(
forceYear = weekEnd.year != weekStart.year, forceYear = weekEnd.year != weekStart.year,
) )
} }
/** The day's title: its weekday and date, abbreviated to the date alone when short of room. */
private fun formatDayTitle(
date: LocalDate,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String =
formatCalendarTitle(
date = date.toJavaLocalDate(),
locale = locale,
currentYear = currentYear,
skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
)
@@ -15,6 +15,8 @@ import kotlinx.datetime.LocalDate
* minimum tap-target height) * minimum tap-target height)
* @param lane 0-based column within [laneCount] * @param lane 0-based column within [laneCount]
* @param laneCount number of columns the event's overlap-cluster needs * @param laneCount number of columns the event's overlap-cluster needs
* @param continuesAfter whether the event runs past this day's midnight, so the
* block is cut there
*/ */
data class TimedBlock( data class TimedBlock(
val event: EventInstance, val event: EventInstance,
@@ -22,6 +24,7 @@ data class TimedBlock(
val endMin: Int, val endMin: Int,
val lane: Int, val lane: Int,
val laneCount: Int, val laneCount: Int,
val continuesAfter: Boolean = false,
) )
/** /**
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.week package de.jeanlucmakiola.calendula.ui.week
import de.jeanlucmakiola.calendula.ui.common.dayRange import de.jeanlucmakiola.calendula.ui.common.dayRange
import de.jeanlucmakiola.calendula.ui.common.renderedSpan
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -13,7 +14,9 @@ import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.domain.displayPriority import de.jeanlucmakiola.calendula.domain.displayPriority
import de.jeanlucmakiola.calendula.domain.displayStart
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.PageStateCache import de.jeanlucmakiola.calendula.ui.common.PageStateCache
@@ -206,8 +209,7 @@ abstract class PagedTimelineViewModel(
): WeekUiState { ): WeekUiState {
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) } calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) } val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
val allDay = instances.filter { it.isAllDay } val (allDay, timed) = instances.splitWholeDays(zone)
val timed = instances.filterNot { it.isAllDay }
return WeekUiState.Success( return WeekUiState.Success(
weekStart = span.start, weekStart = span.start,
today = today, today = today,
@@ -292,20 +294,21 @@ internal fun layoutAllDay(
days: List<LocalDate>, days: List<LocalDate>,
zone: TimeZone, zone: TimeZone,
): List<AllDaySpan> { ): List<AllDaySpan> {
data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int) data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int, val priority: Int)
val raw = events val raw = events
.mapNotNull { ev -> .mapNotNull { ev ->
val covered = days.indices.filter { ev.coversDay(days[it], zone) } val covered = days.indices.filter { ev.coversDay(days[it], zone) }
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last()) if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last(), ev.displayPriority(zone))
} }
// Declined bars are packed after every other one, so on any day they // Declined bars are packed after every other one, so on any day they
// cover they land in a lane below it (#230). // cover they land in a lane below it (#230).
.sortedWith( .sortedWith(
compareBy<Raw> { it.event.displayPriority(zone) } compareBy<Raw> { it.priority }
.thenBy { it.startCol } .thenBy { it.startCol }
.thenBy { it.endCol } .thenBy { it.endCol }
.thenBy { it.event.start } .thenBy { it.event.displayStart(zone) }
.thenBy { it.event.title }
.thenBy { it.event.instanceId }, .thenBy { it.event.instanceId },
) )
@@ -426,12 +429,21 @@ internal fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean {
return coversDay(secondDay, zone) return coversDay(secondDay, zone)
} }
/**
* Split events into those for the all-day strip and those for the time grid:
* all-day events and timed ones from midnight to midnight go to the strip (#296).
*/
internal fun List<EventInstance>.splitWholeDays(zone: TimeZone): Pair<List<EventInstance>, List<EventInstance>> =
partition { it.coversWholeDays(zone) }
/** /**
* Clip [events] to a single [day] and assign lanes so overlapping events render * Clip [events] to a single [day] and assign lanes so overlapping events render
* side-by-side. Lane count is computed per overlap-cluster (a maximal run of * side-by-side. Lane count is computed per overlap-cluster (a maximal run of
* chained-overlapping events), matching the common phone week-view behaviour. * chained-overlapping events), matching the common phone week-view behaviour.
* Overlap is judged on each block's [renderedSpan], so a short block's floor
* (lifted off midnight, #483) never hides another block.
* *
* All-day events are ignored here — they live in the all-day strip. * Takes the timed half of [splitWholeDays]; the rest lives in the all-day strip.
*/ */
internal fun layoutDay( internal fun layoutDay(
events: List<EventInstance>, events: List<EventInstance>,
@@ -441,55 +453,53 @@ internal fun layoutDay(
val dayStart = day.atStartOfDayIn(zone) val dayStart = day.atStartOfDayIn(zone)
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
data class Raw(val event: EventInstance, val startMin: Int, val endMin: Int)
val raw = events.asSequence() val raw = events.asSequence()
.filterNot { it.isAllDay }
.mapNotNull { ev -> .mapNotNull { ev ->
if (ev.start == ev.end) { if (ev.start == ev.end) {
// Instant event: keep only if the point falls inside this day. // Instant event: keep only if the point falls inside this day.
if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null
val m = wallClockMinutes(ev.start, day, zone) val m = wallClockMinutes(ev.start, day, zone)
Raw(ev, m, m) TimedBlock(ev, m, m, lane = 0, laneCount = 1)
} else { } else {
val s = maxOf(ev.start, dayStart) val s = maxOf(ev.start, dayStart)
val e = minOf(ev.end, dayEnd) val e = minOf(ev.end, dayEnd)
if (e <= s) return@mapNotNull null if (e <= s) return@mapNotNull null
val startMin = wallClockMinutes(s, day, zone) val startMin = wallClockMinutes(s, day, zone)
val endMin = wallClockEndMinutes(startMin, s, e, day, zone).coerceAtMost(MINUTES_PER_DAY) val endMin = wallClockEndMinutes(startMin, s, e, day, zone).coerceAtMost(MINUTES_PER_DAY)
Raw(ev, startMin, endMin) TimedBlock(ev, startMin, endMin, lane = 0, laneCount = 1, continuesAfter = ev.end > dayEnd)
} }
} }
.sortedWith(compareBy({ it.startMin }, { it.endMin })) .map { it to it.renderedSpan() }
.sortedWith(compareBy({ it.second.topMin }, { it.second.bottomMin }))
.toList() .toList()
val result = ArrayList<TimedBlock>(raw.size) val result = ArrayList<TimedBlock>(raw.size)
var i = 0 var i = 0
while (i < raw.size) { while (i < raw.size) {
// Grow a cluster of chained-overlapping events. // Grow a cluster of chained-overlapping events.
var clusterEnd = raw[i].endMin var clusterEnd = raw[i].second.bottomMin
var j = i + 1 var j = i + 1
while (j < raw.size && raw[j].startMin < clusterEnd) { while (j < raw.size && raw[j].second.topMin < clusterEnd) {
clusterEnd = maxOf(clusterEnd, raw[j].endMin) clusterEnd = maxOf(clusterEnd, raw[j].second.bottomMin)
j++ j++
} }
val cluster = raw.subList(i, j) val cluster = raw.subList(i, j)
// Greedy first-fit column assignment (= max overlap depth in the cluster). // Greedy first-fit column assignment (= max overlap depth in the cluster).
val laneEnd = ArrayList<Int>() val laneEnd = ArrayList<Float>()
val lanes = IntArray(cluster.size) val lanes = IntArray(cluster.size)
cluster.forEachIndexed { k, r -> cluster.forEachIndexed { k, (_, span) ->
var placed = laneEnd.indexOfFirst { it <= r.startMin } var placed = laneEnd.indexOfFirst { it <= span.topMin }
if (placed == -1) { if (placed == -1) {
laneEnd.add(r.endMin) laneEnd.add(span.bottomMin)
placed = laneEnd.size - 1 placed = laneEnd.size - 1
} else { } else {
laneEnd[placed] = r.endMin laneEnd[placed] = span.bottomMin
} }
lanes[k] = placed lanes[k] = placed
} }
val laneCount = laneEnd.size val laneCount = laneEnd.size
cluster.forEachIndexed { k, r -> cluster.forEachIndexed { k, (block, _) ->
result.add(TimedBlock(r.event, r.startMin, r.endMin, lanes[k], laneCount)) result.add(block.copy(lane = lanes[k], laneCount = laneCount))
} }
i = j i = j
} }
@@ -61,6 +61,7 @@ internal fun WeekViewPreview(
onCreateAt = { _, _ -> }, onCreateAt = { _, _ -> },
onDrop = {}, onDrop = {},
onResize = {}, onResize = {},
onStripDrop = {},
), ),
) )
} }
@@ -75,12 +76,12 @@ private fun sampleWeekState(
): WeekUiState.Success { ): WeekUiState.Success {
val start = if (dayCount == 7) today.startOfWeek(weekStart) else today val start = if (dayCount == 7) today.startOfWeek(weekStart) else today
val days = (0 until dayCount).map { start.plus(it, DateTimeUnit.DAY) } val days = (0 until dayCount).map { start.plus(it, DateTimeUnit.DAY) }
val events = sampleTimelineEvents(days, today, zone) val (allDay, timed) = sampleTimelineEvents(days, today, zone).splitWholeDays(zone)
return WeekUiState.Success( return WeekUiState.Success(
weekStart = start, weekStart = start,
today = today, today = today,
days = days, days = days,
allDaySpans = layoutAllDay(events.filter { it.isAllDay }, days, zone), allDaySpans = layoutAllDay(allDay, days, zone),
timedByDay = days.associateWith { layoutDay(events, it, zone) }, timedByDay = days.associateWith { layoutDay(timed, it, zone) },
) )
} }
@@ -0,0 +1,52 @@
package de.jeanlucmakiola.calendula.widget
import android.annotation.SuppressLint
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
import de.jeanlucmakiola.floret.glance.restoreState
/**
* What every widget receiver shares: the midnight rollover follows whether any
* widget is placed (#228), a restore keeps each widget's settings (#292), and
* the system's `updatePeriodMillis` wake-up reloads the data and re-arms the
* alarm — anything that drops a pending alarm without a broadcast (force-stop,
* battery restriction, an OEM freeze) is repaired there rather than on the
* next app open.
*/
abstract class CalendulaWidgetReceiver : GlanceAppWidgetReceiver() {
override fun onEnabled(context: Context) {
super.onEnabled(context)
syncWidgetRollover(context)
}
override fun onDisabled(context: Context) {
super.onDisabled(context)
syncWidgetRollover(context)
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
evictMonthSources(appWidgetIds)
WidgetKind.entries.firstOrNull { it.receiver == this::class.java }?.let { kind ->
appWidgetIds.forEach { WidgetTicks.cancel(context, kind, it) }
}
}
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
restoreState(context, oldWidgetIds, newWidgetIds)
}
@SuppressLint("MissingSuperCall")
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
// In place of super, which only recomposes and would take the goAsync handle.
reloadOnUpdate(context, appWidgetIds)
syncWidgetRollover(context)
}
}
@@ -0,0 +1,123 @@
package de.jeanlucmakiola.calendula.widget
import android.content.Context
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.glance.GlanceId
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.toEnum
/** A widget's own light/dark choice (#292); [FOLLOW_APP] tracks the app's theme setting. */
enum class WidgetTheme { FOLLOW_APP, LIGHT, DARK }
/** How a widget looks, set per placed widget in its config screen (#292). */
data class WidgetAppearance(
val background: WidgetBackground = WidgetBackground.TINTED,
val theme: WidgetTheme = WidgetTheme.FOLLOW_APP,
/** How opaque a Solid or Tinted fill is (#489). */
val opacity: Float = 1f,
)
/** The agenda widget's look: the 2.24 day cards, or v2.23's plain day headers and stripes (#489). */
enum class AgendaWidgetStyle { CARDS, CLASSIC }
/** What the agenda widget lists, per placed widget. */
data class AgendaWidgetOptions(
val range: AgendaRange,
val textSize: WidgetSize,
val showToday: Boolean,
val pastDisplay: PastEventDisplay,
val style: AgendaWidgetStyle = AgendaWidgetStyle.CARDS,
/** Classic style only: draw the text in the opposite theme's colours, for a faded fill over the wallpaper. */
val invertText: Boolean = false,
)
internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this) {
WidgetTheme.LIGHT -> WidgetThemeMode.LIGHT
WidgetTheme.DARK -> WidgetThemeMode.DARK
WidgetTheme.FOLLOW_APP -> when (app) {
ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK
}
}
private val BackgroundKey = stringPreferencesKey("cfg_background")
private val ThemeKey = stringPreferencesKey("cfg_theme")
private val OpacityKey = floatPreferencesKey("cfg_opacity")
private val AgendaStyleKey = stringPreferencesKey("cfg_agenda_style")
private val InvertTextKey = booleanPreferencesKey("cfg_invert_text")
private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range")
private val TextSizeKey = stringPreferencesKey("cfg_text_size")
private val ShowTodayKey = booleanPreferencesKey("cfg_show_today")
private val PastDisplayKey = stringPreferencesKey("cfg_past_display")
private val HiddenCalendarsKey = stringSetPreferencesKey("cfg_hidden_calendars")
internal val Preferences.widgetAppearance: WidgetAppearance
get() = WidgetAppearance(
background = this[BackgroundKey].toEnum(WidgetBackground.TINTED),
theme = this[ThemeKey].toEnum(WidgetTheme.FOLLOW_APP),
opacity = this[OpacityKey] ?: 1f,
)
/**
* This widget's agenda options. A widget placed before per-widget options
* existed has none stored, and keeps following the app-wide values in
* [defaults] until it is configured.
*/
internal fun Preferences.agendaWidgetOptions(defaults: AgendaWidgetOptions) = AgendaWidgetOptions(
range = parseAgendaRange(this[AgendaRangeKey], defaults.range),
textSize = this[TextSizeKey].toEnum(defaults.textSize),
showToday = this[ShowTodayKey] ?: defaults.showToday,
pastDisplay = this[PastDisplayKey].toEnum(defaults.pastDisplay),
style = this[AgendaStyleKey].toEnum(defaults.style),
invertText = this[InvertTextKey] ?: defaults.invertText,
)
/** Whether this widget has agenda options of its own, rather than following the app's. */
internal val Preferences.hasAgendaOptions: Boolean get() = this[AgendaRangeKey] != null
internal fun MutablePreferences.putAppearance(appearance: WidgetAppearance) {
this[BackgroundKey] = appearance.background.name
this[ThemeKey] = appearance.theme.name
this[OpacityKey] = appearance.opacity
}
internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) {
this[AgendaRangeKey] = options.range.storageValue()
this[TextSizeKey] = options.textSize.name
this[ShowTodayKey] = options.showToday
this[PastDisplayKey] = options.pastDisplay.name
this[AgendaStyleKey] = options.style.name
this[InvertTextKey] = options.invertText
}
/**
* Calendars this widget leaves out, on top of the ones hidden app-wide. Stored
* as the excluded set, so a calendar added later shows up by default.
*/
internal val Preferences.hiddenCalendars: Set<Long>
get() = this[HiddenCalendarsKey]?.mapNotNull { it.toLongOrNull() }?.toSet().orEmpty()
internal fun MutablePreferences.putHiddenCalendars(ids: Set<Long>) {
this[HiddenCalendarsKey] = ids.map(Long::toString).toSet()
}
internal fun List<EventInstance>.withoutCalendars(hidden: Set<Long>): List<EventInstance> =
if (hidden.isEmpty()) this else filterNot { it.calendarId in hidden }
internal suspend fun Context.widgetState(id: GlanceId): Preferences =
getAppWidgetState(this, PreferencesGlanceStateDefinition, id)
@@ -4,7 +4,13 @@ import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidgetManager
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
@@ -13,7 +19,12 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
import de.jeanlucmakiola.calendula.ui.agenda.dayCount import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.get
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -25,6 +36,7 @@ import kotlinx.datetime.plus
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import java.util.Locale import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -34,10 +46,63 @@ import kotlin.time.Instant
* the whole window, and on slow devices a wide one held the widget on "loading" * the whole window, and on slow devices a wide one held the widget on "loading"
* (#363). * (#363).
*/ */
private const val MONTH_WIDGET_PAD_MONTHS = 3 internal const val MONTH_WIDGET_PAD_MONTHS = 3
/**
* Every app setting the widgets draw with: what the loads read, and what
* [WidgetRefreshCoordinator] watches. The agenda fields are only the defaults
* for a widget that has no options of its own yet.
*/
internal data class WidgetSettings(
val soften: Boolean,
val weekStart: WeekStartPref,
val timeFormat: TimeFormatPref,
val themeMode: ThemeMode,
val pastDisplay: PastEventDisplay,
val agendaRange: AgendaRange,
val widgetSize: WidgetSize,
val showToday: Boolean,
) {
/** What every widget draws with; the agenda defaults aside. */
val shared: SharedWidgetSettings get() = SharedWidgetSettings(soften, weekStart, timeFormat, themeMode)
val agendaDefaults: AgendaWidgetOptions
get() = AgendaWidgetOptions(agendaRange, widgetSize, showToday, pastDisplay)
}
/** The [WidgetSettings] a configured widget still takes from the app. */
internal data class SharedWidgetSettings(
val soften: Boolean,
val weekStart: WeekStartPref,
val timeFormat: TimeFormatPref,
val themeMode: ThemeMode,
)
/** A widget's loaded [content], with the app theme a [WidgetTheme.FOLLOW_APP] widget resolves against. */
internal data class Themed<out T>(val content: T, val appTheme: ThemeMode)
internal val SettingsPrefs.widgetSettings: Flow<WidgetSettings>
get() = data.map {
WidgetSettings(
soften = it[SettingsKeys.softenCalendarColors],
weekStart = it[SettingsKeys.weekStart],
timeFormat = it[SettingsKeys.timeFormat],
themeMode = it[SettingsKeys.themeMode],
pastDisplay = it[SettingsKeys.pastEventDisplay],
agendaRange = it[SettingsKeys.agendaWidgetRange],
widgetSize = it[SettingsKeys.widgetSize],
showToday = it[SettingsKeys.agendaShowToday],
)
}.distinctUntilChanged()
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault() internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
/**
* The locale widget text is written in. Glance has no LocalConfiguration, and a
* widget is redrawn on a locale change anyway (LOCALE_CHANGED), so a plain read is enough.
*/
internal fun widgetLocale(): Locale = Locale.getDefault()
internal fun today(zone: TimeZone): LocalDate = internal fun today(zone: TimeZone): LocalDate =
Clock.System.now().toLocalDateTime(zone).date Clock.System.now().toLocalDateTime(zone).date
@@ -51,131 +116,158 @@ sealed interface AgendaWidgetData {
data object NeedsPermission : AgendaWidgetData data object NeedsPermission : AgendaWidgetData
data class Ready( data class Ready(
val today: LocalDate, val today: LocalDate,
/** /** Days with events across the configured range, unfiltered; hiding finished ones happens in the composition. */
* Days with events across the **widest** selectable window. The displayed
* range is sliced from this in the composition (read reactively from
* Glance state), so a range change is pure recomposition rather than a
* flaky widget session restart — the same approach the month widget uses.
* Kept unfiltered: hiding/dimming finished events also happens reactively
* in the composition, against [now], for the same reliable-update reason.
*/
val days: List<AgendaDay>, val days: List<AgendaDay>,
/** Resolved clock convention for event time labels (the time-format pref). */ /** Resolved clock convention for event time labels (the time-format pref). */
val is24Hour: Boolean, val is24Hour: Boolean,
/** Whether event colours are softened to pastels, or shown raw (#36). */ /** Whether event colours are softened to pastels, or shown raw (#36). */
val soften: Boolean, val soften: Boolean,
/** First day of the week, for the calendar-aligned "this week" range. */ val pastDisplay: PastEventDisplay,
val weekStart: DayOfWeek, /** "Always show today" (#35): anchor today at the top even with no events left. */
/** Saved range pref — the fallback when an instance has no Glance state yet. */ val showToday: Boolean,
val savedRange: AgendaRange, val textSize: WidgetSize,
/** Saved past-event display pref — the fallback before Glance state is set. */ val style: AgendaWidgetStyle = AgendaWidgetStyle.CARDS,
val savedPastDisplay: PastEventDisplay, val invertText: Boolean = false,
/**
* Saved "always show today" pref (#35) — anchors today at the top with a
* placeholder even when it has no events left. The fallback read reactively
* from Glance state before an instance has its own state set.
*/
val savedShowToday: Boolean,
/** Saved widget size step (#103) — the fallback before Glance state is set. */
val savedWidgetSize: WidgetSize,
/** Last day [days] covers; a wider range picked later needs a reload. */
val loadedThrough: LocalDate,
/** Snapshot instant the data was read at, for "has this event ended?" tests. */ /** Snapshot instant the data was read at, for "has this event ended?" tests. */
val now: Instant, val now: Instant,
) : AgendaWidgetData ) : AgendaWidgetData
} }
/** /**
* Source data for the month widget: a wide window of instances plus the * Source data for the month widget: a window of instances plus the week-start
* week-start preference and today. The widget computes each displayed month's * preference and today. The widget computes each displayed month's grid from
* grid from this in-memory list (via `layoutMonthWeeks`) as the user pages, * this in-memory list (via `layoutMonthWeeks`) as the user pages, so paging
* so month navigation is pure recomposition — no reload, no flaky widget * inside the loaded window is pure recomposition.
* session restart.
*/ */
sealed interface MonthWidgetSource { sealed interface MonthWidgetSource {
data object NeedsPermission : MonthWidgetSource data object NeedsPermission : MonthWidgetSource
data class Ready( data class Ready(
val today: LocalDate, val today: LocalDate,
val weekStart: DayOfWeek, val weekStart: DayOfWeek,
val soften: Boolean,
val instances: List<EventInstance>, val instances: List<EventInstance>,
/** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */ /** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */
val months: IntRange, val months: IntRange,
) : MonthWidgetSource ) : MonthWidgetSource
} }
/** /** [loadAgendaWidgetData] with agenda widget [id]'s own options. */
* Process-lived cache of the month window. Month navigation can re-run internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed<AgendaWidgetData> {
* `provideGlance`, and re-querying on every arrow tap is what made paging feel val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
* sluggish — so we load once and reuse the same snapshot for every nearby month. Invalidated by val state = widgetState(id)
* [invalidateMonthWidgetCache] when calendar data changes (the freshness val options = state.agendaWidgetOptions(settings.agendaDefaults)
* receiver), and automatically when the day rolls over (the `today` guard). return Themed(loadAgendaWidgetData(settings, options, state.hiddenCalendars), settings.themeMode)
*/
internal object MonthWidgetCache {
@Volatile
var data: MonthWidgetSource.Ready? = null
}
internal fun invalidateMonthWidgetCache() {
MonthWidgetCache.data = null
} }
/** /**
* One-shot read of the upcoming agenda for the widget. Reuses the app's * One-shot read of the upcoming agenda for the widget. Reuses the app's
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's * [agendaRange] window and [groupAgendaDays] grouping, and the repository's
* [first]-emitted snapshot already has hidden calendars filtered out. * [first]-emitted snapshot already has hidden calendars filtered out.
*
* Loads [range] (the widget's own, or the saved pref when null) rather than the
* widest selectable window (#363).
*/ */
internal suspend fun Context.loadAgendaWidgetData(range: AgendaRange? = null): AgendaWidgetData { internal suspend fun Context.loadAgendaWidgetData(
settings: WidgetSettings,
options: AgendaWidgetOptions,
hiddenCalendars: Set<Long>,
): AgendaWidgetData {
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
val zone = systemZone() val zone = systemZone()
val anchor = today(zone) val anchor = today(zone)
val ep = widgetEntryPoint() val ep = widgetEntryPoint()
val prefs = ep.settingsPrefs() val weekStart = settings.weekStart.resolveFirstDay(widgetLocale())
val savedRange = prefs.agendaWidgetRange.first() val dayCount = options.range.dayCount(anchor, weekStart)
val savedPastDisplay = prefs.pastEventDisplay.first()
val showToday = prefs.agendaShowToday.first()
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
// The displayed range is still sliced in the composition from Glance state;
// a wider one picked later reloads there (see loadedThrough).
val dayCount = (range ?: savedRange).dayCount(anchor, weekStart)
val window = agendaRange(anchor, dayCount - 1, zone) val window = agendaRange(anchor, dayCount - 1, zone)
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY) val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
val instances = ep.calendarRepository().instances(window).first() val instances = ep.calendarRepository().instances(window).first().withoutCalendars(hiddenCalendars)
val is24Hour = prefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
val soften = prefs.softenCalendarColors.first()
return AgendaWidgetData.Ready( return AgendaWidgetData.Ready(
today = anchor, today = anchor,
days = groupAgendaDays(anchor, windowEnd, instances, zone), days = groupAgendaDays(anchor, windowEnd, instances, zone),
is24Hour = is24Hour, is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
soften = soften, soften = settings.soften,
weekStart = weekStart, pastDisplay = options.pastDisplay,
savedRange = savedRange, showToday = options.showToday,
savedPastDisplay = savedPastDisplay, textSize = options.textSize,
savedShowToday = showToday, style = options.style,
savedWidgetSize = prefs.widgetSize.first(), invertText = options.invertText,
loadedThrough = windowEnd,
now = Clock.System.now(), now = Clock.System.now(),
) )
} }
internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.ordinal internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.ordinal
private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, index % 12 + 1, 1) internal fun firstDayOfMonthIndex(index: Int): LocalDate =
LocalDate(Math.floorDiv(index, 12), Math.floorMod(index, 12) + 1, 1)
/** Read backing the month widget's grid for [centerIndex] and the months around it. */ /**
internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): MonthWidgetSource { * The month the widget loads its window around while [index] is shown. Snapped
* to steps of three, so paging only reloads every third month, and the window
* always reaches at least two months past [index] either way.
*/
internal fun monthWindowCenter(index: Int): Int = Math.floorDiv(index, 3) * 3 + 1
/**
* The last window each month widget loaded, under its refresh stamp, so a new
* session for the same widget (a tap after the last one timed out) draws
* without a query. Every redraw moves the stamp, so an entry is never served
* stale.
*/
private object MonthSourceCache {
private class Entry(val center: Int, val stamp: Long, val source: Themed<MonthWidgetSource.Ready>)
// Keyed by app-widget id, which a removed widget can still be named by.
private val entries = ConcurrentHashMap<Int, Entry>()
fun get(id: Int, center: Int, stamp: Long): Themed<MonthWidgetSource>? =
entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.content.today == today(systemZone()) }
?.source
fun put(id: Int, center: Int, stamp: Long, source: Themed<MonthWidgetSource>) {
val ready = source.content as? MonthWidgetSource.Ready ?: return
entries[id] = Entry(center, stamp, Themed(ready, source.appTheme))
}
fun remove(id: Int) {
entries.remove(id)
}
}
/** Drops the cached windows of removed widgets [appWidgetIds]. */
internal fun evictMonthSources(appWidgetIds: IntArray) = appWidgetIds.forEach(MonthSourceCache::remove)
/**
* [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds.
* A [load] already read for this pass saves reading the settings and widget state again.
*/
internal suspend fun Context.loadCachedMonthSource(
id: GlanceId,
centerIndex: Int,
stamp: Long,
load: WidgetLoadContext? = null,
): Themed<MonthWidgetSource> {
val key = GlanceAppWidgetManager(this).getAppWidgetId(id)
return MonthSourceCache.get(key, centerIndex, stamp)
?: loadThemedMonthSource(centerIndex, load?.hiddenCalendars ?: widgetState(id).hiddenCalendars, load?.settings)
.also { MonthSourceCache.put(key, centerIndex, stamp, it) }
}
/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */
internal suspend fun Context.loadThemedMonthSource(
centerIndex: Int,
hiddenCalendars: Set<Long>,
settings: WidgetSettings? = null,
): Themed<MonthWidgetSource> {
val resolved = settings ?: widgetEntryPoint().settingsPrefs().widgetSettings.first()
return Themed(queryMonthSource(centerIndex, resolved, hiddenCalendars), resolved.themeMode)
}
private suspend fun Context.queryMonthSource(
centerIndex: Int,
settings: WidgetSettings,
hiddenCalendars: Set<Long>,
): MonthWidgetSource {
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
val zone = systemZone() val zone = systemZone()
val anchor = today(zone)
val center = centerIndex ?: monthIndexOf(anchor)
// Reuse the cached window unless the day changed (then it's stale for "today").
MonthWidgetCache.data?.let { if (it.today == anchor && center in it.months) return it }
val ep = widgetEntryPoint() val ep = widgetEntryPoint()
val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault()) val months = (centerIndex - MONTH_WIDGET_PAD_MONTHS)..(centerIndex + MONTH_WIDGET_PAD_MONTHS)
val months = (center - MONTH_WIDGET_PAD_MONTHS)..(center + MONTH_WIDGET_PAD_MONTHS)
// Padded by the grid's spill into the neighbouring months. // Padded by the grid's spill into the neighbouring months.
val from = firstDayOfMonthIndex(months.first) val from = firstDayOfMonthIndex(months.first)
.minus(1, DateTimeUnit.WEEK) .minus(1, DateTimeUnit.WEEK)
@@ -184,11 +276,79 @@ internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): Mo
.plus(2, DateTimeUnit.WEEK) .plus(2, DateTimeUnit.WEEK)
.atTime(23, 59, 59) .atTime(23, 59, 59)
.toInstant(zone) .toInstant(zone)
val instances = ep.calendarRepository().instances(from..to).first()
return MonthWidgetSource.Ready( return MonthWidgetSource.Ready(
today = anchor, today = today(zone),
weekStart = weekStart, weekStart = settings.weekStart.resolveFirstDay(widgetLocale()),
instances = instances, soften = settings.soften,
instances = ep.calendarRepository().instances(from..to).first().withoutCalendars(hiddenCalendars),
months = months, months = months,
).also { MonthWidgetCache.data = it } )
}
/** Today and the app theme, for a widget that draws no events. */
internal suspend fun Context.loadToday(): Themed<LocalDate> =
Themed(today(systemZone()), widgetEntryPoint().settingsPrefs().widgetSettings.first().themeMode)
/** What a widget drawing a run of days loads: those days' events, grouped like the agenda. */
sealed interface DaysWidgetData {
data object NeedsPermission : DaysWidgetData
data class Ready(
val today: LocalDate,
val firstDay: LocalDate,
val lastDay: LocalDate,
/** Only the days with events, in order. */
val days: List<AgendaDay>,
val weekStart: DayOfWeek,
val is24Hour: Boolean,
val soften: Boolean,
val options: AgendaWidgetOptions,
val now: Instant,
) : DaysWidgetData {
fun eventsOn(date: LocalDate): List<EventInstance> = days.firstOrNull { it.date == date }?.events.orEmpty()
}
}
/** Widget [id]'s stored options and calendars, with the app's values for what it has none of. */
internal class WidgetLoadContext(val settings: WidgetSettings, val options: AgendaWidgetOptions, val hiddenCalendars: Set<Long>)
internal suspend fun Context.widgetLoadContext(id: GlanceId): WidgetLoadContext {
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
val state = widgetState(id)
return WidgetLoadContext(settings, state.agendaWidgetOptions(settings.agendaDefaults), state.hiddenCalendars)
}
/** The events on the days [days] picks from today and the week start, for [load]'s widget. */
internal suspend fun Context.loadDays(
load: WidgetLoadContext,
days: (today: LocalDate, weekStart: DayOfWeek) -> ClosedRange<LocalDate>,
): Themed<DaysWidgetData> {
val settings = load.settings
if (!hasCalendarPermission()) return Themed(DaysWidgetData.NeedsPermission, settings.themeMode)
val zone = systemZone()
val anchor = today(zone)
val weekStart = settings.weekStart.resolveFirstDay(widgetLocale())
val range = days(anchor, weekStart)
val window = range.start.atStartOfDayIn(zone)..range.endInclusive.atTime(23, 59, 59).toInstant(zone)
val instances = widgetEntryPoint().calendarRepository().instances(window).first().withoutCalendars(load.hiddenCalendars)
return Themed(
DaysWidgetData.Ready(
today = anchor,
firstDay = range.start,
lastDay = range.endInclusive,
days = groupAgendaDays(range.start, range.endInclusive, instances, zone),
weekStart = weekStart,
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
soften = settings.soften,
options = load.options,
now = Clock.System.now(),
),
settings.themeMode,
)
}
/** The week [today] falls in, from [weekStart]. */
internal fun weekOf(today: LocalDate, weekStart: DayOfWeek): ClosedRange<LocalDate> {
val back = (today.dayOfWeek.ordinal - weekStart.ordinal + 7) % 7
val first = today.minus(back, DateTimeUnit.DAY)
return first..first.plus(6, DateTimeUnit.DAY)
} }
@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
interface WidgetEntryPoint { interface WidgetEntryPoint {
fun calendarRepository(): CalendarRepository fun calendarRepository(): CalendarRepository
fun settingsPrefs(): SettingsPrefs fun settingsPrefs(): SettingsPrefs
fun widgetRefreshCoordinator(): WidgetRefreshCoordinator
} }
internal fun Context.widgetEntryPoint(): WidgetEntryPoint = internal fun Context.widgetEntryPoint(): WidgetEntryPoint =
@@ -0,0 +1,127 @@
package de.jeanlucmakiola.calendula.widget
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.action.clickable
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
import de.jeanlucmakiola.floret.glance.themedColor
/** How much of a past event's tone stays in its dimmed fill (#90). */
private const val WidgetDimFillAlpha = 0.3f
/** A dimmed event's ink, held above its fill. */
private const val WidgetDimInkAlpha = 0.65f
/**
* An event's fill in the light or [dark] theme. A dimmed one is its tone faded
* into that theme's surface, kept opaque: a translucent tint lands on the row
* shape's white, not on the widget, and its ink would be picked for the wrong tone (#475).
*/
internal fun widgetEventFill(rawArgb: Int, dark: Boolean, soften: Boolean, dimmed: Boolean): Color {
val tone = eventFill(rawArgb, dark, soften)
if (!dimmed) return tone
val surface = if (dark) CalendulaDarkFallback.surface else Color.White
return tone.copy(alpha = WidgetDimFillAlpha).compositeOver(surface)
}
/** Ink for an event on [fill], faded when the row is [dimmed]. */
internal fun widgetEventInk(fill: Color, alpha: Float, dimmed: Boolean): Color =
eventInk(fill, if (dimmed) alpha * WidgetDimInkAlpha else alpha)
/** Text and row sizes of a compact event list, from a widget's [WidgetSize]. */
internal data class EventRowMetrics(val title: TextUnit, val time: TextUnit, val rowHeight: Dp, val message: TextUnit)
/** The agenda widget's sizes with tighter rows, resolved against the system font scale. */
internal fun compactRowMetrics(size: WidgetSize, fontScale: Float): EventRowMetrics {
val agenda = metricsFor(size)
return EventRowMetrics(
title = agenda.eventTitle,
time = agenda.eventTime,
rowHeight = agenda.contentH * fontScale + agenda.rowVPad,
message = agenda.message,
)
}
/**
* One event as a two-line row in its calendar's fill, part of a grouped card
* at [position]; opens the occurrence. [quiet] draws it in the card colour
* instead, for events shown only as a look ahead.
*/
@Composable
internal fun WidgetEventRow(
event: EventInstance,
secondary: String,
soften: Boolean,
metrics: EventRowMetrics,
position: Position,
source: CalendarView,
dimmed: Boolean = false,
quiet: Boolean = false,
) {
val context = LocalContext.current
val day = widgetEventFill(event.color, dark = false, soften, dimmed)
val night = widgetEventFill(event.color, dark = true, soften, dimmed)
val card = widgetCardColor()
WidgetGroupedRow(position = position, color = if (quiet) card else themedColor(day = day, night = night)) {
Column(
modifier = GlanceModifier
.fillMaxWidth()
.height(metrics.rowHeight)
.padding(horizontal = 12.dp)
.clickable(
actionStartActivity(
event.openIntent(context, source),
),
),
verticalAlignment = Alignment.CenterVertically,
) {
val titleInk = if (quiet) {
GlanceTheme.colors.onSurface
} else {
themedColor(day = widgetEventInk(day, 1f, dimmed), night = widgetEventInk(night, 1f, dimmed))
}
val timeInk = if (quiet) {
GlanceTheme.colors.onSurfaceVariant
} else {
themedColor(day = widgetEventInk(day, SECONDARY_INK_ALPHA, dimmed), night = widgetEventInk(night, SECONDARY_INK_ALPHA, dimmed))
}
Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1,
style = TextStyle(
color = titleInk,
fontSize = metrics.title,
fontWeight = FontWeight.Medium,
textDecoration = glanceDeclinedDecoration(event.isCrossedOut),
),
)
Text(text = secondary, maxLines = 1, style = TextStyle(color = timeInk, fontSize = metrics.time))
}
}
}
@@ -0,0 +1,130 @@
package de.jeanlucmakiola.calendula.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.glance.appwidget.GlanceAppWidget
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidget
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetReceiver
import de.jeanlucmakiola.calendula.widget.date.DateWidget
import de.jeanlucmakiola.calendula.widget.date.DateWidgetReceiver
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidget
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetReceiver
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidget
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetReceiver
import de.jeanlucmakiola.calendula.widget.today.TodayWidget
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetReceiver
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidget
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetReceiver
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidget
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver
/** A content option a widget's config screen offers, besides background and theme. */
enum class WidgetOption { STYLE, RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS }
/**
* Every widget the app ships, told apart by their receiver: what redraws,
* the rollover and the config screen go through.
*/
enum class WidgetKind(
val receiver: Class<*>,
val label: Int,
val options: Set<WidgetOption>,
/** The size a config preview draws at before the launcher reports one. */
val defaultSize: DpSize,
) {
AGENDA(
AgendaWidgetReceiver::class.java,
R.string.widget_agenda_label,
setOf(
WidgetOption.STYLE,
WidgetOption.RANGE,
WidgetOption.TEXT_SIZE,
WidgetOption.PAST_DISPLAY,
WidgetOption.SHOW_TODAY,
WidgetOption.CALENDARS,
),
DpSize(260.dp, 280.dp),
),
MONTH(
MonthWidgetReceiver::class.java,
R.string.widget_month_label,
setOf(WidgetOption.CALENDARS),
DpSize(320.dp, 340.dp),
),
QUICK_ADD(
QuickAddWidgetReceiver::class.java,
R.string.widget_quick_add_label,
emptySet(),
DpSize(76.dp, 76.dp),
),
DATE(
DateWidgetReceiver::class.java,
R.string.widget_date_label,
emptySet(),
DpSize(76.dp, 76.dp),
),
WEEK_STRIP(
WeekStripWidgetReceiver::class.java,
R.string.widget_week_strip_label,
setOf(WidgetOption.CALENDARS),
DpSize(300.dp, 90.dp),
),
UPCOMING(
UpcomingWidgetReceiver::class.java,
R.string.widget_upcoming_label,
setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.CALENDARS),
DpSize(180.dp, 180.dp),
),
TODAY(
TodayWidgetReceiver::class.java,
R.string.widget_today_label,
setOf(WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.CALENDARS),
DpSize(300.dp, 100.dp),
),
COUNTDOWN(
CountdownWidgetReceiver::class.java,
R.string.widget_countdown_label,
setOf(WidgetOption.CALENDARS),
DpSize(200.dp, 100.dp),
),
MONTH_DAY(
MonthDayWidgetReceiver::class.java,
R.string.widget_month_day_label,
setOf(WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.CALENDARS),
DpSize(320.dp, 200.dp),
),
;
/** Whether a calendar change can change what it draws; Date and Quick add draw no events. */
val drawsEvents: Boolean get() = this != QUICK_ADD && this != DATE
fun widget(): GlanceAppWidget = when (this) {
AGENDA -> AgendaWidget()
MONTH -> MonthWidget()
QUICK_ADD -> QuickAddWidget()
DATE -> DateWidget()
WEEK_STRIP -> WeekStripWidget()
UPCOMING -> UpcomingWidget()
TODAY -> TodayWidget()
COUNTDOWN -> CountdownWidget()
MONTH_DAY -> MonthDayWidget()
}
/** Whether this kind stores [AgendaWidgetOptions] of its own. */
val hasContentOptions: Boolean
get() = options.any { it != WidgetOption.CALENDARS }
companion object {
fun of(context: Context, appWidgetId: Int): WidgetKind? {
val provider = AppWidgetManager.getInstance(context)?.getAppWidgetInfo(appWidgetId)?.provider ?: return null
return entries.firstOrNull { it.receiver.name == provider.className }
}
}
}
@@ -0,0 +1,75 @@
package de.jeanlucmakiola.calendula.widget
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.glance.GlanceTheme
import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import androidx.glance.appwidget.action.actionStartActivity
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
@Composable
internal fun widgetCardColor(): ColorProvider = when (LocalWidgetBackground.current) {
WidgetBackground.SOLID -> GlanceTheme.colors.surfaceVariant
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground
}
/** Opens this occurrence in the app, backing out to [source]. */
internal fun EventInstance.openIntent(context: Context, source: CalendarView) = MainActivity.openEventIntent(
context = context,
eventId = eventId,
beginMillis = start.toEpochMilliseconds(),
endMillis = end.toEpochMilliseconds(),
source = source,
)
/** Opens [date] in the app's agenda. */
internal fun openDayAction(context: Context, date: LocalDate) =
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda))
/** An event's time on [day]: "All day", "Starts 14:00", "Ends 9:00" or "14:00 – 15:30". */
internal fun widgetTimeLabel(context: Context, event: EventInstance, day: LocalDate, is24Hour: Boolean): String =
when (val label = agendaTimeLabel(event, day, systemZone())) {
AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day)
is AgendaTimeLabel.Starts -> context.getString(R.string.agenda_span_starts, formatWidgetTime(label.start, is24Hour))
is AgendaTimeLabel.Ends -> context.getString(R.string.agenda_span_ends, formatWidgetTime(label.end, is24Hour))
is AgendaTimeLabel.Range -> "${formatWidgetTime(label.start, is24Hour)} – ${formatWidgetTime(label.end, is24Hour)}"
}
internal fun formatWidgetTime(instant: Instant, is24Hour: Boolean): String {
val t = instant.toLocalDateTime(systemZone()).time
return formatTimeOfDay(t.hour, t.minute, is24Hour, widgetLocale())
}
/** "Today" or "Tomorrow" for those two days, else null. */
internal fun relativeDayWord(context: Context, date: LocalDate, today: LocalDate): String? = when (date) {
today -> context.getString(R.string.agenda_header_today)
today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
else -> null
}
/** "Today", "Tomorrow", or the short weekday within the coming week, else the short date. */
internal fun relativeDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
val locale = widgetLocale()
return relativeDayWord(context, date, today) ?: when {
date < today.plus(7, DateTimeUnit.DAY) && date > today ->
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, locale)
else -> localizedDateFormatter(locale, "dMMM").format(date.toJavaLocalDate())
}
}
@@ -0,0 +1,191 @@
package de.jeanlucmakiola.calendula.widget
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.countdown.CountdownData
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetContent
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetReceiver
import de.jeanlucmakiola.calendula.widget.date.DateWidgetContent
import de.jeanlucmakiola.calendula.widget.date.DateWidgetReceiver
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetContent
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetReceiver
import de.jeanlucmakiola.calendula.widget.split.MonthDayData
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetContent
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetContent
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetReceiver
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetContent
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetReceiver
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetContent
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetReceiver
import de.jeanlucmakiola.floret.glance.publishWidgetPreviews
import kotlinx.coroutines.flow.first
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
/**
* Republishes the generated picker previews (Android 15+) when anything they
* draw with has changed: the app version, language, today's date, or the
* theme, week start, time format and colour softening.
*/
internal suspend fun publishCalendulaWidgetPreviews(context: Context) {
val settings = context.widgetEntryPoint().settingsPrefs().widgetSettings.first()
val updated = context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime
val is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
publishWidgetPreviews(
context,
version = listOf(
updated,
widgetLocale().toLanguageTag(),
today(systemZone()),
settings.themeMode,
settings.weekStart,
is24Hour,
settings.soften,
).joinToString("-"),
receivers = listOf(
AgendaWidgetReceiver::class,
MonthWidgetReceiver::class,
QuickAddWidgetReceiver::class,
DateWidgetReceiver::class,
WeekStripWidgetReceiver::class,
UpcomingWidgetReceiver::class,
TodayWidgetReceiver::class,
CountdownWidgetReceiver::class,
MonthDayWidgetReceiver::class,
),
)
}
/** The size [kind]'s picker preview is laid out at. */
internal fun previewSizeOf(kind: WidgetKind) = SizeMode.Responsive(setOf(kind.defaultSize))
/** [kind]'s picker preview: the real widget, drawn with sample events around today's morning. */
internal suspend fun GlanceAppWidget.provideSamplePreview(context: Context, kind: WidgetKind) {
val settings = context.widgetEntryPoint().settingsPrefs().widgetSettings.first()
val zone = systemZone()
val today = today(zone)
val now = today.atTime(8, 0).toInstant(zone)
val instances = context.sampleInstances(today, zone)
val weekStart = settings.weekStart.resolveFirstDay(widgetLocale())
val is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
val options = settings.agendaDefaults
val appearance = WidgetAppearance()
val index = monthIndexOf(today)
val month = MonthWidgetSource.Ready(today, weekStart, settings.soften, instances, index..index)
fun days(range: ClosedRange<LocalDate>) = DaysWidgetData.Ready(
today = today,
firstDay = range.start,
lastDay = range.endInclusive,
days = groupAgendaDays(range.start, range.endInclusive, instances, zone),
weekStart = weekStart,
is24Hour = is24Hour,
soften = settings.soften,
options = options,
now = now,
)
val theme = settings.themeMode
provideContent {
when (kind) {
WidgetKind.AGENDA -> AgendaWidgetContent(
AgendaWidgetData.Ready(
today = today,
days = groupAgendaDays(today, today.plus(6, DateTimeUnit.DAY), instances, zone),
is24Hour = is24Hour,
soften = settings.soften,
pastDisplay = options.pastDisplay,
showToday = options.showToday,
textSize = options.textSize,
now = now,
),
appearance,
theme,
preview = true,
)
WidgetKind.MONTH -> MonthWidgetContent(month, index, appearance, theme)
WidgetKind.MONTH_DAY -> MonthDayWidgetContent(
MonthDayData.Ready(month, options, is24Hour, now),
index,
selected = null,
appearance = appearance,
appTheme = theme,
preview = true,
)
WidgetKind.QUICK_ADD -> QuickAddWidgetContent(today, appearance, theme)
WidgetKind.DATE -> DateWidgetContent(today, appearance, theme)
WidgetKind.WEEK_STRIP -> WeekStripWidgetContent(days(weekOf(today, weekStart)), appearance, theme)
WidgetKind.UPCOMING -> UpcomingWidgetContent(days(today..today.plus(6, DateTimeUnit.DAY)), appearance, theme)
WidgetKind.TODAY -> TodayWidgetContent(days(today..today.plus(1, DateTimeUnit.DAY)), appearance, theme, preview = true)
WidgetKind.COUNTDOWN -> CountdownWidgetContent(
CountdownData.Ready(
options = CountdownOptions(),
event = instances.first { !it.isAllDay && it.start > now },
now = now,
today = today,
is24Hour = is24Hour,
soften = settings.soften,
appWidgetId = null,
),
appearance,
theme,
)
}
}
}
/** A plausible few weeks: a busy today, a trip tomorrow, and a scatter of days with something on. */
private fun Context.sampleInstances(today: LocalDate, zone: TimeZone): List<EventInstance> {
var id = 0L
fun timed(day: Int, at: LocalTime, length: kotlin.time.Duration, title: Int, color: Int): EventInstance {
val start = today.plus(day, DateTimeUnit.DAY).atTime(at).toInstant(zone)
id++
return EventInstance(id, id, 1, getString(title), start, start + length, false, color, null)
}
fun allDay(day: Int, title: Int, color: Int): EventInstance {
val date = today.plus(day, DateTimeUnit.DAY)
id++
return EventInstance(
id, id, 1, getString(title),
date.atStartOfDayIn(TimeZone.UTC), date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(TimeZone.UTC),
true, color, null,
)
}
return listOf(
timed(0, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
timed(0, LocalTime(12, 30), 1.hours, R.string.settings_past_events_sample_midday, Green),
timed(0, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
allDay(1, R.string.widget_preview_sample_trip, Orange),
timed(2, LocalTime(10, 0), 1.hours, R.string.widget_preview_sample_dentist, Red),
timed(-6, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
timed(-2, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
timed(5, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
timed(8, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
timed(12, LocalTime(12, 30), 1.hours, R.string.settings_past_events_sample_midday, Green),
).sortedBy { it.start }
}
private const val Blue = 0xFF039BE5.toInt()
private const val Green = 0xFF33B679.toInt()
private const val Purple = 0xFF8E24AA.toInt()
private const val Orange = 0xFFF4511E.toInt()
private const val Red = 0xFFD50000.toInt()
@@ -0,0 +1,188 @@
package de.jeanlucmakiola.calendula.widget
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.SystemClock
import android.util.Log
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.widget.countdown.repointCountdowns
import de.jeanlucmakiola.floret.glance.WidgetRefresher
import de.jeanlucmakiola.floret.glance.WidgetRollover
import de.jeanlucmakiola.floret.glance.updatePlaced
import de.jeanlucmakiola.floret.glance.updateWithRefresh
import androidx.glance.appwidget.GlanceAppWidgetManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
import javax.inject.Singleton
/** Every widget receiver; the set [WidgetRollover] checks for placed instances. */
internal val WIDGET_RECEIVER_CLASSES: List<Class<*>> = WidgetKind.entries.map { it.receiver }
/** The app's own midnight wake-up (#228). */
internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
/** Redraws every widget, for app changes they draw with that no other signal reaches, like its language (#90). */
internal const val ACTION_WIDGET_REDRAW = "de.jeanlucmakiola.calendula.widget.REDRAW"
/**
* Asks [WidgetRedrawReceiver] to redraw every widget. A broadcast rather than a
* coroutine, so it outlives the activity a language change recreates.
*/
fun requestWidgetRedraw(context: Context) {
context.sendBroadcast(Intent(context, WidgetRedrawReceiver::class.java).setAction(ACTION_WIDGET_REDRAW))
}
/** The widgets a calendar change can affect. */
internal val EVENT_WIDGET_KINDS: List<WidgetKind> = WidgetKind.entries.filter { it.drawsEvents }
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
internal fun widgetRolloverPendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
context,
ROLLOVER_REQUEST_CODE,
Intent(context, WidgetUpdateReceiver::class.java).setAction(ACTION_WIDGET_ROLLOVER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** Arm the next midnight rollover, or cancel it when no widget is placed. Idempotent. */
internal fun syncWidgetRollover(context: Context) =
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
/**
* Reloads and redraws every placed widget, a running session included: the
* refresh stamp moves, and every widget keys its data load on it (#420).
*/
internal suspend fun redrawWidgets(context: Context, kinds: List<WidgetKind> = WidgetKind.entries) {
// One kind failing, say a widget removed mid-pass, mustn't stop the others
// or escape the broadcast's coroutine.
kinds.forEach { kind ->
try {
redrawWidgets(context, kind)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Redrawing $kind widgets failed", e)
}
}
}
private const val TAG = "WidgetRedraw"
internal suspend fun redrawWidgets(context: Context, kind: WidgetKind) =
kind.widget().updatePlaced(context, kind.receiver, bumpRefreshStamp = true)
/** Redraws only the widgets with no content options of their own, which still follow the app's. */
private suspend fun redrawUnconfiguredWidgets(context: Context) {
val appContext = context.applicationContext
val manager = AppWidgetManager.getInstance(appContext) ?: return
val glanceManager = GlanceAppWidgetManager(appContext)
WidgetKind.entries.filter { it.hasContentOptions }.forEach { kind ->
manager.getAppWidgetIds(ComponentName(appContext, kind.receiver)).forEach { appWidgetId ->
try {
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
if (!appContext.widgetState(glanceId).hasAgendaOptions) kind.widget().updateWithRefresh(appContext, glanceId)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Redrawing widget $appWidgetId failed", e)
}
}
}
}
/** When the app last saw a calendar change of its own, for skipping the `PROVIDER_CHANGED` that echoes it. */
private val lastLocalChange = AtomicLong(0L)
private const val PROVIDER_ECHO_WINDOW_MILLIS = 5_000L
/** Set while a `PROVIDER_CHANGED` redraw waits out the echo window, so a burst of them redraws once. */
private val echoRedrawQueued = AtomicBoolean(false)
private fun providerEchoRemainingMillis(): Long {
val last = lastLocalChange.get()
if (last == 0L) return 0L
return PROVIDER_ECHO_WINDOW_MILLIS - (SystemClock.elapsedRealtime() - last)
}
/**
* Whether a `PROVIDER_CHANGED` should redraw. Within the echo window of one of
* the app's own changes it may only echo that change, or be a sync landing
* right after it, so it isn't dropped: the first one redraws once the window
* has passed ([awaitProviderEcho]) and the rest of the burst ride along.
*/
internal fun claimProviderRedraw(): Boolean =
providerEchoRemainingMillis() <= 0L || echoRedrawQueued.compareAndSet(false, true)
/** Waits out the echo window, if one is open, before a `PROVIDER_CHANGED` redraw. */
internal suspend fun awaitProviderEcho() {
try {
val wait = providerEchoRemainingMillis()
if (wait > 0L) delay(wait)
} finally {
echoRedrawQueued.set(false)
}
}
/**
* Redraws the widgets on the app's own calendar writes, visibility changes and
* the settings they draw with, so those don't wait for `PROVIDER_CHANGED`,
* which arrives seconds late or not at all (#420, #229). Changes from outside
* the app are [WidgetUpdateReceiver]'s. The app's agenda options only redraw
* widgets that have no content options of their own (#292). Started once from
* `CalendulaApp`.
*/
@Singleton
class WidgetRefreshCoordinator @Inject constructor(
@ApplicationContext private val context: Context,
private val repository: CalendarRepository,
private val prefs: SettingsPrefs,
@ApplicationScope private val scope: CoroutineScope,
) {
private val started = AtomicBoolean(false)
/** Subscribes before returning, so a change made right after is never taken for the starting value. */
fun start() {
if (!started.compareAndSet(false, true)) return
val refreshers = WidgetKind.entries.associateWith { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } }
val all = refreshers.values
val unconfigured = WidgetRefresher(scope) { redrawUnconfiguredWidgets(context) }
watch(repository.localChanges) {
lastLocalChange.set(SystemClock.elapsedRealtime())
EVENT_WIDGET_KINDS.forEach { refreshers.getValue(it).request() }
}
watch(repository.seriesSplits) { split ->
scope.launch(Dispatchers.IO) { repointCountdowns(context, split.oldEventId, split.newEventId) }
}
watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) {
all.forEach { it.request() }
scope.launch(Dispatchers.IO) { publishCalendulaWidgetPreviews(context) }
}
watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
unconfigured.request()
}
}
private fun <T> watch(changes: Flow<T>, onChange: (T) -> Unit) {
scope.launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) {
changes.collect { onChange(it) }
}
}
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.calendula.widget
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import de.jeanlucmakiola.floret.android.goAsync
import de.jeanlucmakiola.floret.locale.AppLanguage
/**
* Takes [requestWidgetRedraw]'s in-app redraw. Not exported, unlike
* [WidgetUpdateReceiver], so no other app can make every widget reload.
*/
class WidgetRedrawReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_WIDGET_REDRAW) return
val appContext = context.applicationContext
goAsync {
AppLanguage.applyDefault(appContext)
redrawWidgets(appContext)
}
}
}
@@ -1,115 +0,0 @@
package de.jeanlucmakiola.calendula.widget
import android.app.AlarmManager
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.core.content.getSystemService
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* Holds the app's own wake-up for the next local midnight, so the home-screen
* widgets roll "today" over on the day boundary (#228).
*
* The widgets used to lean on `ACTION_DATE_CHANGED`, which is not an exempted
* implicit broadcast — a manifest-declared receiver has not been given it since
* Android 8, leaving only the throttled `updatePeriodMillis`.
*
* Exactly one alarm exists at a time and every firing re-arms the next, the same
* shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
* Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
* survives doze (plain `set` does not), a rollover a few minutes late is
* invisible on a sleeping screen, and exact alarms stay reserved for snooze.
*/
object WidgetRolloverScheduler {
/**
* Fire just *after* midnight: an alarm delivered a few milliseconds early
* would still read the old date and re-arm for an instant later.
*/
internal val ROLLOVER_SLACK = 5.seconds
/**
* Arm the next rollover, or cancel a pending one when no widget is placed.
* Idempotent, so every trigger (boot, app start, widget added/removed, the
* rollover itself, a clock or timezone change) can just call it.
*/
fun sync(context: Context) {
val appContext = context.applicationContext
val alarmManager = appContext.getSystemService<AlarmManager>() ?: return
val pendingIntent = rolloverPendingIntent(appContext)
if (!hasPlacedWidgets(appContext)) {
alarmManager.cancel(pendingIntent)
return
}
val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
)
}
/**
* The instant just after the next local midnight following [now] in [zone].
*
* The *actual* start of day, not 00:00, so it holds where a DST jump means
* midnight never happens (Havana) and where a date-line move skips a whole
* local date (Apia, December 2011) — the loop walks on to the next real day.
*
* The mirror case — a zone rewinding *across* midnight, so the day starts
* twice — resolves to the earlier start and runs an hour ahead of the clock.
* No live tz entry does that (Brazil dropped DST in 2019) and
* `updatePeriodMillis` covers it, so it isn't worth state to detect.
*/
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
val date = now.toLocalDateTime(zone).date
var days = 1
while (days <= MAX_LOOKAHEAD_DAYS) {
val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
if (candidate > now) return candidate
days++
}
// Unreachable for any zone in the tz database. Deliberately an hour and
// not the slack: a 5-second retry would just hit this branch again and
// wake the device in a loop.
return now + 1.hours
}
private fun hasPlacedWidgets(context: Context): Boolean {
val manager = AppWidgetManager.getInstance(context) ?: return false
return PROVIDERS.any {
manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty()
}
}
private fun rolloverPendingIntent(context: Context): PendingIntent =
PendingIntent.getBroadcast(
context,
ROLLOVER_REQUEST_CODE,
Intent(context, WidgetUpdateReceiver::class.java)
.setAction(WidgetUpdateReceiver.ACTION_ROLLOVER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private val PROVIDERS = listOf(
MonthWidgetReceiver::class.java,
AgendaWidgetReceiver::class.java,
)
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
/** A gap of more than a couple of days does not exist in any tz database entry. */
private const val MAX_LOOKAHEAD_DAYS = 3
}
@@ -1,44 +1,63 @@
package de.jeanlucmakiola.calendula.widget package de.jeanlucmakiola.calendula.widget
import android.os.Build
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.glance.GlanceTheme import androidx.compose.runtime.CompositionLocalProvider
import androidx.glance.material3.ColorProviders import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.glance.LocalContext
import androidx.glance.text.TextDecoration import androidx.glance.text.TextDecoration
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
import de.jeanlucmakiola.floret.glance.WidgetSurface
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import de.jeanlucmakiola.floret.locale.AppLanguage
import de.jeanlucmakiola.floret.prefs.ThemeMode
/** /**
* Brand fallback for devices without Material You dynamic colour (API < 31). * Glance equivalent of `CalendulaTheme`: Material You on API 31+, the app's
* Reuses the exact same hand-tuned schemes as the in-app theme * hand-tuned fallback schemes below that, in the light/dark [mode] the widget
* ([CalendulaLightFallback] / [CalendulaDarkFallback]) so a widget on an older * was configured with (#292).
* device matches the app surface-for-surface.
*/
private val CalendulaGlanceColors = ColorProviders(
light = CalendulaLightFallback,
dark = CalendulaDarkFallback,
)
/**
* Glance equivalent of `CalendulaTheme`. On API 31+ it follows the system's
* Material You palette (so the widget matches the home screen / the app's
* dynamic colour); below that it falls back to the brand scheme. Either way the
* widget draws only from M3 colour-role tokens (`GlanceTheme.colors.*`) — never
* a hardcoded colour — so it tracks light/dark automatically.
*/ */
@Composable @Composable
fun CalendulaGlanceTheme(content: @Composable () -> Unit) { fun CalendulaGlanceTheme(mode: WidgetThemeMode, content: @Composable () -> Unit) {
val colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { FloretGlanceTheme(
GlanceTheme.colors lightScheme = CalendulaLightFallback,
} else { darkScheme = CalendulaDarkFallback,
CalendulaGlanceColors dynamicColor = true,
mode = mode,
content = content,
)
}
/** A widget's root: its configured theme and background around [content]. */
@Composable
internal fun CalendulaWidgetRoot(appearance: WidgetAppearance, appTheme: ThemeMode, content: @Composable () -> Unit) {
CalendulaGlanceTheme(appearance.theme.resolve(appTheme)) {
WidgetSurface(background = appearance.background, opacity = appearance.opacity, content = content)
} }
GlanceTheme(colors = colors, content = content)
} }
/** /**
* Glance's counterpart to the app's `declinedDecoration`: a declined invitation * Glance's counterpart to the app's `declinedDecoration`: a declined invitation
* reads the same on the home screen as it does inside the app (#180). * reads the same on the home screen as it does inside the app (#180).
*/ */
fun glanceDeclinedDecoration(isDeclined: Boolean): TextDecoration = fun glanceDeclinedDecoration(isCrossedOut: Boolean): TextDecoration =
if (isDeclined) TextDecoration.LineThrough else TextDecoration.None if (isCrossedOut) TextDecoration.LineThrough else TextDecoration.None
/** Static, so a language change recomposes the whole widget rather than letting unchanged parts skip. */
private val LocalWidgetLanguage = staticCompositionLocalOf<String?> { null }
/**
* Draws [content] in the app's own language (#90): [LocalContext] and the
* default locale follow it, which below API 33 nothing else does outside an
* AppCompat activity. Read on every recomposition, so a redraw picks up a change.
*/
@Composable
internal fun InAppLanguage(content: @Composable () -> Unit) {
val base = LocalContext.current
val tag = AppLanguage.currentTag(base)
AppLanguage.applyDefault(base, tag)
val localized = remember(base, tag) { AppLanguage.localized(base, tag) }
CompositionLocalProvider(LocalContext provides localized, LocalWidgetLanguage provides tag, content = content)
}
@@ -0,0 +1,79 @@
package de.jeanlucmakiola.calendula.widget
import android.app.AlarmManager
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.net.toUri
import androidx.glance.appwidget.GlanceAppWidgetManager
import de.jeanlucmakiola.floret.android.goAsync
import de.jeanlucmakiola.floret.glance.updateWithRefresh
import kotlinx.coroutines.CancellationException
import kotlin.time.Instant
/**
* One alarm per placed widget, for the moment what it shows next changes: a
* countdown's label, the Today list's last event ending. Not a wake-up alarm:
* with the screen off nobody sees the widget, so the redraw waits for the
* device to wake. Exact where allowed (the app holds the exact alarm
* permission for reminders), else within a minute.
*/
internal object WidgetTicks {
private const val ACTION_TICK = "de.jeanlucmakiola.calendula.widget.TICK"
internal const val EXTRA_KIND = "de.jeanlucmakiola.calendula.widget.KIND"
fun schedule(context: Context, kind: WidgetKind, appWidgetId: Int, at: Instant) {
val alarms = context.getSystemService(AlarmManager::class.java) ?: return
val intent = pendingIntent(context, kind, appWidgetId)
val millis = at.toEpochMilliseconds()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()) {
alarms.setExact(AlarmManager.RTC, millis, intent)
} else {
alarms.setWindow(AlarmManager.RTC, millis, 60_000L, intent)
}
}
fun cancel(context: Context, kind: WidgetKind, appWidgetId: Int) {
context.getSystemService(AlarmManager::class.java)?.cancel(pendingIntent(context, kind, appWidgetId))
}
private fun pendingIntent(context: Context, kind: WidgetKind, appWidgetId: Int): PendingIntent =
PendingIntent.getBroadcast(
context,
0,
Intent(context, WidgetTickReceiver::class.java)
.setAction(ACTION_TICK)
// Keys the PendingIntent per widget.
.setData("calendula://tick/${kind.name}/$appWidgetId".toUri())
.putExtra(EXTRA_KIND, kind.name)
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
/** Reloads the widget whose content just changed; the load arms its next tick. */
class WidgetTickReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
val kind = intent.getStringExtra(WidgetTicks.EXTRA_KIND)
?.let { name -> WidgetKind.entries.firstOrNull { it.name == name } }
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID || kind == null) return
val appContext = context.applicationContext
goAsync {
try {
val glanceId = GlanceAppWidgetManager(appContext).getGlanceIdBy(appWidgetId)
kind.widget().updateWithRefresh(appContext, glanceId)
} catch (e: CancellationException) {
throw e
} catch (_: IllegalArgumentException) {
// The widget is gone.
WidgetTicks.cancel(appContext, kind, appWidgetId)
} catch (_: Exception) {
// A failed redraw leaves the last frame; the next signal retries.
}
}
}
}
@@ -1,120 +1,42 @@
package de.jeanlucmakiola.calendula.widget package de.jeanlucmakiola.calendula.widget
import android.content.BroadcastReceiver import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.SystemClock import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver
import androidx.glance.appwidget.updateAll import de.jeanlucmakiola.floret.locale.AppLanguage
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget import kotlin.time.Duration.Companion.minutes
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicLong
/** /**
* Redraws both home-screen widgets when their data goes stale, and keeps the * Redraws the widgets when their data, day or language goes stale, and keeps
* midnight rollover alarm armed. Triggered by: * the midnight rollover armed. `PROVIDER_CHANGED` covers changes made outside
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change, * the app, and only redraws the widgets that draw events; the app's own writes
* so it covers both the app's own writes and external sync. * go through [WidgetRefreshCoordinator]. A data change also re-arms, at most
* - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] — * every 15 minutes: the inexact alarm can be dropped in a restricted standby
* the day boundary, so "today" highlighting and the agenda's past-event * bucket, and a calendar change is the one signal that keeps arriving. A
* dimming move on (#228). * `PROVIDER_CHANGED` right after one of the app's own changes waits out the
* - `TIME_SET` / `TIMEZONE_CHANGED` — the day boundary moved, so redraw *and* * echo window and redraws once ([claimProviderRedraw]).
* re-arm.
* - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the
* latter is also what arms installs upgrading into the fix.
*
* `DATE_CHANGED` rides along as a free extra the system may or may not send —
* see [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the
* provider XML and the month widget's refresh button.
*
* Every action that dropped, consumed or invalidated the alarm re-arms it, and
* `PROVIDER_CHANGED` re-arms at most once every [DATA_REARM_INTERVAL_MILLIS]
* ([dataRearmDue]). The alarm is inexact, so the system may drop it outright in
* a restricted standby bucket; nothing in ordinary daily use would then notice,
* and the widget sits on yesterday's date. A calendar change is the one signal
* that keeps arriving — but it arrives on *every* write (each drag, each event
* of a thousand-event import), which is what the interval is for.
*
* Exported for the system broadcasts; an extra redraw from another app is
* harmless.
*/ */
class WidgetUpdateReceiver : BroadcastReceiver() { class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
override fun onReceive(context: Context, intent: Intent) { override val rolloverAction = ACTION_WIDGET_ROLLOVER
// Exported, so anything can reach it with an explicit intent. Nothing override val widgetReceiverClasses = WIDGET_RECEIVER_CLASSES
// here crosses a trust boundary, but narrowing to the actions we asked override val extraHandledActions = setOf(Intent.ACTION_PROVIDER_CHANGED)
// for keeps a stray broadcast from costing two wide provider reads. override val extraRearmInterval = 15.minutes
if (intent.action !in HANDLED_ACTIONS) return
val appContext = context.applicationContext /** This broadcast's action; a receiver instance handles exactly one. */
val rearm = intent.action in REARM_ACTIONS || private var action: String? = null
(intent.action == Intent.ACTION_PROVIDER_CHANGED && dataRearmDue())
// The host sends APPWIDGET_UPDATE after the re-arm-only ones anyway, so override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context)
// redrawing there would only repeat the work in a cold process, at the
// moment the device is most contended. override fun skipRedraw(action: String): Boolean {
val redraw = intent.action !in REARM_ONLY_ACTIONS this.action = action
val pending = goAsync() return action == Intent.ACTION_PROVIDER_CHANGED && !claimProviderRedraw()
// Calendar data may have changed (sync / our own write) — drop the cached
// month window so the widgets reload fresh. Month paging does NOT call
// this, so arrow taps stay instant.
if (redraw) invalidateMonthWidgetCache()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
// Re-armed first, so the next day boundary is covered whatever
// the redraw does — and off the receiving thread, since it is a
// handful of binder calls (`CalendulaApp` moves it for the same
// reason).
if (rearm) WidgetRolloverScheduler.sync(appContext)
if (redraw) {
AgendaWidget().updateAll(appContext)
MonthWidget().updateAll(appContext)
}
} finally {
pending.finish()
}
}
} }
companion object { override suspend fun redraw(context: Context) {
/** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */ val dataChange = action == Intent.ACTION_PROVIDER_CHANGED
const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER" if (dataChange) awaitProviderEcho()
AppLanguage.applyDefault(context)
/** Both wipe pending alarms, and the host redraws the widgets itself after them. */ redrawWidgets(context, if (dataChange) EVENT_WIDGET_KINDS else WidgetKind.entries)
private val REARM_ONLY_ACTIONS = setOf(
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
)
/**
* The actions that leave no alarm standing: the two above wipe it, the
* rollover consumed itself, and a clock, zone or day change moved the
* boundary the pending one was set for.
*/
internal val REARM_ACTIONS = REARM_ONLY_ACTIONS + setOf(
ACTION_ROLLOVER,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
Intent.ACTION_DATE_CHANGED,
)
internal val HANDLED_ACTIONS = REARM_ACTIONS + Intent.ACTION_PROVIDER_CHANGED
/** How rarely a calendar change may re-arm; see the class doc. */
private const val DATA_REARM_INTERVAL_MILLIS = 15 * 60_000L
/** Uptime of the last data-change re-arm, 0 for none in this process. */
private val lastDataRearmMillis = AtomicLong(0L)
/**
* Whether a calendar change may re-arm now — the backstop for an alarm
* the system dropped, throttled so a burst of writes costs one pass.
*/
private fun dataRearmDue(): Boolean {
val now = SystemClock.elapsedRealtime()
val last = lastDataRearmMillis.get()
val due = last == 0L || now - last >= DATA_REARM_INTERVAL_MILLIS
return due && lastDataRearmMillis.compareAndSet(last, now)
}
} }
} }
@@ -6,17 +6,20 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.calendula.widget.WidgetSize import de.jeanlucmakiola.calendula.widget.WidgetSize
/** /** Horizontal inset of a row's text inside its card; doesn't scale, so it never eats title width. */
* Horizontal layout constants for an agenda event row. These don't scale with the internal val ROW_H_PAD = 12.dp
* size step, since a wider stripe or gap would eat title width, but [TEXT_INDENT]
* is derived from them so non-event text can't drift out of alignment. /** Space between the date-pill gutter and a day's card. */
*/ internal val GUTTER_GAP = 6.dp
internal val ROW_H_PAD = 4.dp
/** The classic style's row inset, colour stripe and the gap after it (#489). */
internal val CLASSIC_ROW_H_PAD = 4.dp
internal val STRIPE_WIDTH = 5.dp internal val STRIPE_WIDTH = 5.dp
internal val STRIPE_GAP = 10.dp internal val STRIPE_GAP = 10.dp
/** Left indent that lines non-event text up with the event title column. */ /** The stripe and its gap, and where the classic style's event text starts, so its placeholder lines up with the titles. */
internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP internal val STRIPE_INSET = STRIPE_WIDTH + STRIPE_GAP
internal val TEXT_INDENT = CLASSIC_ROW_H_PAD + STRIPE_INSET
/** /**
* Every size the agenda widget varies by [WidgetSize]. Values that genuinely * Every size the agenda widget varies by [WidgetSize]. Values that genuinely
@@ -24,89 +27,100 @@ internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
* constants rather than routing through here. * constants rather than routing through here.
*/ */
internal data class AgendaMetrics( internal data class AgendaMetrics(
val title: TextUnit, // header "Upcoming" val pillWeekday: TextUnit, // date pill weekday ("Thu")
val dayHeader: TextUnit, // day label ("Today · …") val pillDay: TextUnit, // date pill day number
val eventTitle: TextUnit, // event title line val eventTitle: TextUnit, // event title line
val eventTime: TextUnit, // time/location line val eventTime: TextUnit, // time/location line
val placeholder: TextUnit, // "no more events today" (#35) val placeholder: TextUnit, // "no more events today" (#35)
val message: TextUnit, // empty/permission centred message val message: TextUnit, // empty/permission centred message
val stripeH: Dp, // coloured event stripe height val contentH: Dp, // an event row's two text lines
val iconImage: Dp, // header action icon glyph val pillWidth: Dp, // date pill / gutter width
val iconBox: Dp, // header action touch target
val rowVPad: Dp, // event row vertical padding val rowVPad: Dp, // event row vertical padding
val dayHeaderTopPad: Dp, // space above a day header val dayGap: Dp, // space between two days' cards
val monthLabel: TextUnit, // month name above a new month's first day
val dayHeader: TextUnit, // classic style's day label ("Today · …")
val dayHeaderTopPad: Dp, // classic style's space above a day label
) { ) {
/** One event row's height, shared by the date pill beside it and the placeholder row. */
val rowHeight: Dp get() = contentH + rowVPad * 2
/** /**
* Resolves the stripe height against the user's system font scale: the stripe * Resolves the row content height and pill width against the user's system font
* is a [Dp] while the lines beside it are `sp`, so without this the text * scale: both are [Dp] while the text in and beside them is `sp`, so without
* outgrows it at large font settings. * this the text outgrows them at large font settings.
*/ */
fun scaledForFont(fontScale: Float): AgendaMetrics = fun scaledForFont(fontScale: Float): AgendaMetrics =
if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale) if (fontScale == 1f) this else copy(contentH = contentH * fontScale, pillWidth = pillWidth * fontScale)
} }
/* /*
* Type sizes are anchored to the Material 3 type scale, with two deviations: * Type sizes are anchored to the Material 3 type scale, with one deviation:
* *
* ‡ SMALL's 13sp day header is off-scale, held there so SMALL reproduces the
* widget's original constants verbatim (#51).
* † Above Title Medium the M3 scale jumps 16 → 22 → 24, too coarse for four * † Above Title Medium the M3 scale jumps 16 → 22 → 24, too coarse for four
* steps; interpolated values are used where a role would force a ≥1.4x jump. * steps; interpolated values are used where a role would force a ≥1.4x jump.
*/ */
private val SMALL_METRICS = AgendaMetrics( private val SMALL_METRICS = AgendaMetrics(
title = 16.sp, // M3 Title Medium pillWeekday = 11.sp,
dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline pillDay = 16.sp,
eventTitle = 14.sp, // M3 Body Medium eventTitle = 14.sp, // M3 Body Medium
eventTime = 12.sp, // M3 Body Small eventTime = 12.sp, // M3 Body Small
placeholder = 14.sp, // M3 Body Medium placeholder = 14.sp, // M3 Body Medium
message = 14.sp, // M3 Body Medium message = 14.sp, // M3 Body Medium
stripeH = 36.dp, contentH = 36.dp,
iconImage = 22.dp, pillWidth = 40.dp,
iconBox = 40.dp,
rowVPad = 4.dp, rowVPad = 4.dp,
dayGap = 8.dp,
monthLabel = 12.sp,
dayHeader = 13.sp,
dayHeaderTopPad = 10.dp, dayHeaderTopPad = 10.dp,
) )
private val MEDIUM_METRICS = AgendaMetrics( private val MEDIUM_METRICS = AgendaMetrics(
title = 18.sp, // † pillWeekday = 12.sp,
dayHeader = 14.sp, // M3 Title Small pillDay = 18.sp,
eventTitle = 16.sp, // M3 Body Large eventTitle = 16.sp, // M3 Body Large
eventTime = 14.sp, // M3 Body Medium eventTime = 14.sp, // M3 Body Medium
placeholder = 16.sp, // M3 Body Large placeholder = 16.sp, // M3 Body Large
message = 16.sp, // M3 Body Large message = 16.sp, // M3 Body Large
stripeH = 40.dp, contentH = 40.dp,
iconImage = 24.dp, pillWidth = 44.dp,
iconBox = 44.dp,
rowVPad = 5.dp, rowVPad = 5.dp,
dayGap = 9.dp,
monthLabel = 14.sp,
dayHeader = 14.sp, // M3 Title Small
dayHeaderTopPad = 11.dp, dayHeaderTopPad = 11.dp,
) )
private val LARGE_METRICS = AgendaMetrics( private val LARGE_METRICS = AgendaMetrics(
title = 20.sp, // † pillWeekday = 12.sp,
dayHeader = 16.sp, // M3 Title Medium pillDay = 20.sp,
eventTitle = 18.sp, // † eventTitle = 18.sp, // †
eventTime = 14.sp, // M3 Body Medium — the secondary line steps more slowly eventTime = 14.sp, // M3 Body Medium — the secondary line steps more slowly
placeholder = 18.sp, // † on purpose, so the title keeps its placeholder = 18.sp, // † on purpose, so the title keeps its
message = 18.sp, // † lead and the hierarchy survives. message = 18.sp, // † lead and the hierarchy survives.
stripeH = 46.dp, contentH = 46.dp,
iconImage = 26.dp, pillWidth = 48.dp,
iconBox = 48.dp,
rowVPad = 6.dp, rowVPad = 6.dp,
dayGap = 10.dp,
monthLabel = 14.sp,
dayHeader = 16.sp, // M3 Title Medium
dayHeaderTopPad = 12.dp, dayHeaderTopPad = 12.dp,
) )
private val EXTRA_LARGE_METRICS = AgendaMetrics( private val EXTRA_LARGE_METRICS = AgendaMetrics(
title = 22.sp, // M3 Title Large pillWeekday = 14.sp,
dayHeader = 18.sp, // † pillDay = 22.sp,
eventTitle = 20.sp, // † eventTitle = 20.sp, // †
eventTime = 16.sp, // M3 Body Large eventTime = 16.sp, // M3 Body Large
placeholder = 20.sp, // † placeholder = 20.sp, // †
message = 20.sp, // † message = 20.sp, // †
stripeH = 52.dp, contentH = 52.dp,
iconImage = 28.dp, pillWidth = 52.dp,
iconBox = 52.dp,
rowVPad = 8.dp, rowVPad = 8.dp,
dayGap = 12.dp,
monthLabel = 16.sp,
dayHeader = 18.sp, // †
dayHeaderTopPad = 14.dp, dayHeaderTopPad = 14.dp,
) )
@@ -1,40 +1,31 @@
package de.jeanlucmakiola.calendula.widget.agenda package de.jeanlucmakiola.calendula.widget.agenda
import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.widget.InAppLanguage
import de.jeanlucmakiola.calendula.widget.relativeDayWord
import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context import android.content.Context
import android.content.res.Configuration
import androidx.compose.runtime.Composable 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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.ColorFilter
import androidx.glance.GlanceId import androidx.glance.GlanceId
import androidx.glance.GlanceModifier import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider import androidx.glance.ImageProvider
import androidx.glance.action.ActionParameters
import androidx.glance.action.clickable import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.provideContent
import androidx.glance.background import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.layout.Alignment import androidx.glance.layout.Alignment
import androidx.glance.layout.Box import androidx.glance.layout.Box
@@ -47,78 +38,67 @@ import androidx.glance.layout.height
import androidx.glance.layout.padding import androidx.glance.layout.padding
import androidx.glance.layout.size import androidx.glance.layout.size
import androidx.glance.layout.width import androidx.glance.layout.width
import androidx.glance.semantics.contentDescription
import androidx.glance.semantics.semantics
import androidx.glance.text.TextAlign
import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.floret.glance.WidgetHeader
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
import androidx.glance.text.FontWeight import androidx.glance.text.FontWeight
import androidx.glance.text.Text import androidx.glance.text.Text
import androidx.glance.text.TextStyle import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.calendula.widget.widgetEventFill
import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.widget.AgendaWidgetStyle
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
import de.jeanlucmakiola.floret.glance.LocalWidgetSeeThrough
import androidx.compose.runtime.CompositionLocalProvider
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.withAlpha
import de.jeanlucmakiola.calendula.widget.widgetEventInk
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.WidgetSize import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetCardColor
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
import de.jeanlucmakiola.calendula.widget.openIntent
import de.jeanlucmakiola.calendula.widget.openDayAction
import de.jeanlucmakiola.calendula.widget.loadAgendaWidget
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
import de.jeanlucmakiola.floret.glance.themedColor
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.prefs.ThemeMode
import androidx.datastore.preferences.core.Preferences
import androidx.glance.currentState
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today import de.jeanlucmakiola.calendula.widget.today
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toJavaDayOfWeek
import kotlin.time.Instant
import java.util.Locale
/**
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
* The range is read reactively in the composition ([currentState]) so a settings
* change is reflected by plain recomposition — `updateAll` does NOT reliably
* re-run the `provideGlance` preamble for a live widget session, so loading the
* range there and slicing it would silently keep the stale value (mirrors the
* month widget's MONTH_INDEX_KEY approach).
*/
internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
/**
* Per-instance Glance state key holding the past-event display mode (as
* [PastEventDisplay.name]). Read reactively in the composition for the same
* reason as [AGENDA_RANGE_KEY] — so toggling the setting reflects on the live
* widget without depending on the `provideGlance` preamble re-running.
*/
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
/**
* Per-instance Glance state key holding the "always show today" toggle (#35).
* Read reactively in the composition for the same reason as [AGENDA_RANGE_KEY] —
* so toggling the setting reflects on the live widget without depending on the
* `provideGlance` preamble re-running.
*/
internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today")
/**
* Per-instance Glance state key holding the chosen [WidgetSize]. Read reactively
* in the composition for the same reason as [AGENDA_RANGE_KEY] — so changing the
* setting reflects on the live widget without depending on the `provideGlance`
* preamble re-running.
*/
internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size")
/** /**
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days * "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
* of events grouped under day headers (the Google "Schedule" widget model). * of events: each day one card of grouped rows with its date pill beside it, or
* in the classic style a label line over plain colour-stripe rows (#489).
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda. * Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
*/ */
class AgendaWidget : GlanceAppWidget() { class AgendaWidget : GlanceAppWidget() {
@@ -131,32 +111,49 @@ class AgendaWidget : GlanceAppWidget() {
// the RemoteViews past the binder transaction limit. // the RemoteViews past the binder transaction limit.
override val sizeMode = SizeMode.Single override val sizeMode = SizeMode.Single
override val previewSizeMode = previewSizeOf(WidgetKind.AGENDA)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.AGENDA)
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val stored = getAppWidgetState<Preferences>(context, id)[AGENDA_RANGE_KEY] val stamp = refreshStamp(context, id)
val data = context.loadAgendaWidgetData(stored?.let { parseAgendaRange(it, AgendaRange.Month) }) val widgetData = WidgetData(context.loadAgendaWidget(id), stamp)
val dark = (context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
provideContent { provideContent {
CalendulaGlanceTheme { // A running session never re-runs this preamble, so every redraw
AgendaWidgetBody(data = data, dark = dark) // moves the stamp and the data reloads here instead (#420).
val loaded = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidget(id) }
InAppLanguage {
AgendaWidgetContent(
data = loaded.content,
appearance = currentState<Preferences>().widgetAppearance,
appTheme = loaded.appTheme,
)
} }
} }
} }
} }
/** /**
* Redraws the widget (header refresh button). Targets the tapped widget's own id * The whole agenda widget, shared with its config screen's preview (#292). The
* rather than `updateAll`, whose provider-name lookup is empty in a process a tap * [preview] draws its rows as a plain column, which RemoteViews can show
* woke from cold — see `ShiftMonthAction` (#18). A cold process re-reads the * outside a widget host on every Android version.
* calendar in the `provideGlance` preamble; a live session only recomposes from
* the snapshot it already has (see [AGENDA_RANGE_KEY]).
*/ */
class RefreshAgendaAction : ActionCallback { @Composable
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { internal fun AgendaWidgetContent(
AgendaWidget().update(context.applicationContext, glanceId) data: AgendaWidgetData,
appearance: WidgetAppearance,
appTheme: ThemeMode,
preview: Boolean = false,
) {
CalendulaWidgetRoot(appearance, appTheme) {
AgendaWidgetBody(data = data, preview = preview)
} }
} }
/** Rows a preview draws: Glance's cap on a Column's children, still more than a placed widget shows. */
private const val PREVIEW_ROWS = 10
/** /**
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews * Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
* stays well inside the binder transaction limit regardless of range and calendar * stays well inside the binder transaction limit regardless of range and calendar
@@ -164,58 +161,38 @@ class RefreshAgendaAction : ActionCallback {
* user scrolling a home-screen widget past a hundred rows is not a case worth * user scrolling a home-screen widget past a hundred rows is not a case worth
* risking a failed update for. * risking a failed update for.
*/ */
private const val MAX_AGENDA_ROWS = 100 internal const val MAX_AGENDA_ROWS = 100
/** Flat row model so the [LazyColumn] can mix day headers and events. */ /** Flat row model: one [LazyColumn] item per event or placeholder, a day's first one carrying its date pill. */
private sealed interface AgendaRow { internal sealed interface AgendaRow {
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow val date: LocalDate
data class Event(val date: LocalDate, val event: EventInstance) : AgendaRow
/** "Nothing left today" line under an anchored, event-less today (#35). */ data class Event(override val date: LocalDate, val event: EventInstance) : AgendaRow
data class Placeholder(val date: LocalDate) : AgendaRow /** "Nothing left today" line for an anchored, event-less today (#35). */
data class Placeholder(override val date: LocalDate) : AgendaRow
} }
@Composable @Composable
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) {
// Read reactively from per-instance Glance state, falling back to the saved // The row height is re-resolved against the system font scale so it tracks the
// pref for a freshly placed widget (#103, #51).
// The stripe is then re-resolved against the system font scale so it tracks the
// sp-sized text beside it instead of drifting at large accessibility settings. // sp-sized text beside it instead of drifting at large accessibility settings.
val savedSize = (data as? AgendaWidgetData.Ready)?.savedWidgetSize ?: WidgetSize.SMALL val size = (data as? AgendaWidgetData.Ready)?.textSize ?: WidgetSize.SMALL
val size = currentState(AGENDA_SIZE_KEY)
?.let { stored -> WidgetSize.entries.firstOrNull { it.name == stored } }
?: savedSize
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
val metrics = metricsFor(size).scaledForFont(fontScale) val metrics = metricsFor(size).scaledForFont(fontScale)
Column( // The classic style adds no backings for a faded fill, only on a transparent one (#489).
modifier = GlanceModifier val classic = (data as? AgendaWidgetData.Ready)?.style == AgendaWidgetStyle.CLASSIC
.fillMaxSize() val seeThrough = if (classic) LocalWidgetBackground.current == WidgetBackground.TRANSPARENT else LocalWidgetSeeThrough.current
.background(GlanceTheme.colors.surface) CompositionLocalProvider(LocalWidgetSeeThrough provides seeThrough) {
.padding(horizontal = 8.dp, vertical = 6.dp), Column(
) { modifier = GlanceModifier
AgendaHeader(metrics) .fillMaxSize()
Spacer(GlanceModifier.height(4.dp)) .padding(horizontal = 8.dp, vertical = 6.dp),
when (data) { ) {
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics) AgendaHeader()
is AgendaWidgetData.Ready -> { Spacer(GlanceModifier.height(6.dp))
// Range read reactively from per-instance Glance state (falls back when (data) {
// to the saved pref for a freshly placed widget), then the loaded AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
// window is sliced to it — pure recomposition. is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview)
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
val context = androidx.glance.LocalContext.current
var loaded by remember(data) { mutableStateOf<AgendaWidgetData.Ready>(data) }
// A range wider than what was loaded reloads in a live session,
// where provideGlance doesn't run again (#363).
LaunchedEffect(range) {
val end = loaded.today.plus(
range.dayCount(loaded.today, loaded.weekStart) - 1,
DateTimeUnit.DAY,
)
if (end > loaded.loadedThrough) {
(context.loadAgendaWidgetData(range) as? AgendaWidgetData.Ready)
?.let { loaded = it }
}
}
AgendaRows(data = loaded, range = range, dark = dark, metrics = metrics)
} }
} }
} }
@@ -224,24 +201,16 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
@Composable @Composable
private fun AgendaRows( private fun AgendaRows(
data: AgendaWidgetData.Ready, data: AgendaWidgetData.Ready,
range: AgendaRange,
dark: Boolean,
metrics: AgendaMetrics, metrics: AgendaMetrics,
preview: Boolean,
) { ) {
val pastDisplay = val pastDisplay = data.pastDisplay
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay) // Drop finished events (and any day they leave empty) when the mode is
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday // Hide — so "Upcoming" really is. Then re-anchor today (as an empty
val rangeEnd = data.today.plus( // placeholder day) when the "always show today" pref is on and today has
range.dayCount(data.today, data.weekStart) - 1, // no events left (#35).
DateTimeUnit.DAY,
)
// Slice to the chosen range, then drop finished events (and any day
// they leave empty) when the mode is Hide — so "Upcoming" really is.
// Finally re-anchor today (as an empty placeholder day) when the
// "always show today" pref is on and today has no events left (#35).
val visibleDays = anchorTodayIfMissing( val visibleDays = anchorTodayIfMissing(
days = data.days days = data.days
.filter { it.date <= rangeEnd }
.let { days -> .let { days ->
if (pastDisplay == PastEventDisplay.HIDE) { if (pastDisplay == PastEventDisplay.HIDE) {
days.mapNotNull { day -> days.mapNotNull { day ->
@@ -253,14 +222,13 @@ private fun AgendaRows(
} }
}, },
today = data.today, today = data.today,
enabled = showToday, enabled = data.showToday,
) )
if (visibleDays.isEmpty()) { if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title, metrics) WidgetMessage(R.string.agenda_empty_title, metrics)
} else { } else {
val rows = buildList { val rows = buildList {
visibleDays.forEach { day -> visibleDays.forEach { day ->
add(AgendaRow.Header(day.date, data.today))
if (day.events.isEmpty()) { if (day.events.isEmpty()) {
add(AgendaRow.Placeholder(day.date)) add(AgendaRow.Placeholder(day.date))
} else { } else {
@@ -268,143 +236,373 @@ private fun AgendaRows(
} }
} }
} }
// Bound the payload, then drop a day header the cut left when (data.style) {
// stranded with nothing under it. AgendaWidgetStyle.CARDS -> CardRows(rows, data, metrics, preview)
.take(MAX_AGENDA_ROWS) AgendaWidgetStyle.CLASSIC -> ClassicRows(rows, data, metrics, preview)
.dropLastWhile { it is AgendaRow.Header }
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(rows.size) { index ->
when (val row = rows[index]) {
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
is AgendaRow.Event -> EventRow(
event = row.event,
day = row.date,
dark = dark,
soften = data.soften,
is24Hour = data.is24Hour,
dimmed = pastDisplay == PastEventDisplay.DIM &&
row.event.hasEnded(data.now),
metrics = metrics,
)
}
}
} }
} }
} }
/** Each day is one card of grouped rows (#71), with its date pill beside the first row. */
@Composable @Composable
private fun AgendaHeader(metrics: AgendaMetrics) { private fun CardRows(allRows: List<AgendaRow>, data: AgendaWidgetData.Ready, metrics: AgendaMetrics, preview: Boolean) {
val context = androidx.glance.LocalContext.current val rows = allRows.take(MAX_AGENDA_ROWS)
Row( val positions = dayPositions(rows.map { it.date })
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp), val cardColor = widgetCardColor()
verticalAlignment = Alignment.CenterVertically, val item: @Composable (Int) -> Unit = { index ->
) { val row = rows[index]
// Tap the "Upcoming" title to open the app on the user's default view val position = positions[index]
// (issue #20) — a null target resolves to the default home in the host. val first = position == Position.Top || position == Position.Alone
Text( val dayRow: @Composable (GlanceModifier) -> Unit = { modifier ->
text = context.getString(R.string.widget_agenda_title), Row(modifier = modifier.fillMaxWidth()) {
style = TextStyle( Box(modifier = GlanceModifier.width(metrics.pillWidth)) {
color = GlanceTheme.colors.primary, if (first) DatePill(row.date, data.today, cardColor, metrics)
fontSize = metrics.title, }
fontWeight = FontWeight.Medium, Spacer(GlanceModifier.width(GUTTER_GAP))
), Box(modifier = GlanceModifier.defaultWeight()) {
modifier = GlanceModifier val rowColor = when (row) {
.defaultWeight() is AgendaRow.Event -> row.fill(data).let { themedColor(day = it.day, night = it.night) }
.clickable( is AgendaRow.Placeholder -> cardColor
actionStartActivity(MainActivity.openViewIntent(context, view = null)), }
), WidgetGroupedRow(position = position, color = rowColor) {
) AgendaRowContent(row, data, metrics)
IconButton( }
resId = R.drawable.ic_widget_refresh, }
contentDescription = context.getString(R.string.widget_refresh), }
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()), }
metrics = metrics, val gap = GlanceModifier.padding(top = if (first && index > 0) metrics.dayGap else 0.dp)
) // The pills carry no month, so a new month gets its name above its first day.
IconButton( if (first && index > 0 && row.date.month != rows[index - 1].date.month) {
resId = R.drawable.ic_widget_add, Column(modifier = gap.fillMaxWidth()) {
contentDescription = context.getString(R.string.widget_new_event), MonthLabel(row.date, data.today, metrics)
onClick = GlanceModifier.clickable( dayRow(GlanceModifier)
actionStartActivity( }
MainActivity.openCreateIntent(context, today(systemZone())), } else {
), dayRow(gap)
), }
metrics = metrics, }
) RowList(rows.size, preview, item)
}
/** [count] rows of [item]: a plain column in a [preview], else a [LazyColumn]. */
@Composable
private fun RowList(count: Int, preview: Boolean, item: @Composable (Int) -> Unit) {
if (preview) {
Column(modifier = GlanceModifier.fillMaxSize()) {
repeat(minOf(count, PREVIEW_ROWS)) { item(it) }
}
} else {
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(count) { item(it) }
}
} }
} }
@Composable /** A classic list entry: a day's label line, or one of its rows. */
private fun IconButton( internal sealed interface ClassicItem {
resId: Int, val date: LocalDate
contentDescription: String,
onClick: GlanceModifier, data class Header(override val date: LocalDate) : ClassicItem
metrics: AgendaMetrics, data class Entry(val row: AgendaRow) : ClassicItem {
) { override val date: LocalDate get() = row.date
Box(
modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
contentAlignment = Alignment.Center,
) {
Image(
provider = ImageProvider(resId),
contentDescription = contentDescription,
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
modifier = GlanceModifier.size(metrics.iconImage),
)
} }
} }
/**
* [rows] with a label before each day's first, held to [limit] items in all,
* headers included; a label the cut leaves with nothing under it is dropped.
*/
internal fun classicItems(rows: List<AgendaRow>, limit: Int = MAX_AGENDA_ROWS): List<ClassicItem> = buildList {
rows.forEachIndexed { index, row ->
if (index == 0 || row.date != rows[index - 1].date) add(ClassicItem.Header(row.date))
add(ClassicItem.Entry(row))
}
}.take(limit).dropLastWhile { it is ClassicItem.Header }
/**
* v2.23's look (#489): a label line per day over plain rows with a colour
* stripe. On a transparent root each day sits on its own backing card, so the
* text stays readable over the wallpaper; a faded fill is left to the opacity
* and "Invert text colour" options.
*/
@Composable @Composable
private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) { private fun ClassicRows(rows: List<AgendaRow>, data: AgendaWidgetData.Ready, metrics: AgendaMetrics, preview: Boolean) {
val items = classicItems(rows, if (preview) PREVIEW_ROWS else MAX_AGENDA_ROWS)
val backed = LocalWidgetBackground.current == WidgetBackground.TRANSPARENT
val ink = classicInk(inverted = data.invertText && !backed)
val positions = if (backed) dayPositions(items.map { it.date }) else emptyList()
val cardColor = widgetCardColor()
val item: @Composable (Int) -> Unit = { index ->
val entry = items[index]
val content: @Composable () -> Unit = {
when (entry) {
is ClassicItem.Header -> DayHeaderRow(
date = entry.date,
today = data.today,
startPad = if (backed) CLASSIC_CARD_H_PAD else 8.dp,
topPad = when {
backed -> CLASSIC_CARD_TOP_PAD
index == 0 -> 0.dp
else -> metrics.dayHeaderTopPad
},
ink = ink,
metrics = metrics,
)
is ClassicItem.Entry -> when (val row = entry.row) {
is AgendaRow.Placeholder -> ClassicPlaceholderRow(row.date, backed, ink, metrics)
is AgendaRow.Event -> ClassicEventRow(row, data, backed, ink, metrics)
}
}
}
if (backed) {
val gap = if (entry is ClassicItem.Header && index > 0) metrics.dayGap else 0.dp
WidgetGroupedRow(
position = positions[index],
modifier = GlanceModifier.padding(top = gap),
color = cardColor,
gapBelow = false,
content = content,
)
} else {
content()
}
}
RowList(items.size, preview, item)
}
/** The classic style's text colours: the theme's, or its inverse for the "Invert text colour" option. */
private data class ClassicInk(val title: ColorProvider, val secondary: ColorProvider, val today: ColorProvider)
@Composable
private fun classicInk(inverted: Boolean): ClassicInk = with(GlanceTheme.colors) {
if (inverted) {
ClassicInk(inverseOnSurface, inverseOnSurface.withAlpha(INVERTED_SECONDARY_INK), inversePrimary)
} else {
ClassicInk(onSurface, onSurfaceVariant, primary)
}
}
/** A classic day label, "Today · Wed, 17 Jun"; opens that day in the app. */
@Composable
private fun DayHeaderRow(date: LocalDate, today: LocalDate, startPad: Dp, topPad: Dp, ink: ClassicInk, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current val context = androidx.glance.LocalContext.current
Text( Text(
text = agendaDayLabel(context, date, today), text = agendaDayLabel(context, date, today),
maxLines = 1,
style = TextStyle( style = TextStyle(
color = if (date == today) GlanceTheme.colors.primary color = if (date == today) ink.today else ink.secondary,
else GlanceTheme.colors.onSurfaceVariant,
fontSize = metrics.dayHeader, fontSize = metrics.dayHeader,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
), ),
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxWidth() .fillMaxWidth()
.padding( .padding(start = startPad, end = 8.dp, top = topPad, bottom = metrics.rowVPad)
start = 8.dp, .clickable(openDayAction(context, date)),
end = 8.dp,
top = metrics.dayHeaderTopPad,
bottom = metrics.rowVPad,
)
.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
),
),
) )
} }
/** /** "No more events today" as a muted line under an anchored, event-less today (#35), indented to the titles. */
* "No more events today" under an anchored, event-less today (#35). Kept plain —
* a muted line indented to the event titles — since the widget's event rows are
* plain too (a stripe + text, not cards), so a card here would look out of place.
*/
@Composable @Composable
private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) { private fun ClassicPlaceholderRow(date: LocalDate, backed: Boolean, ink: ClassicInk, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current val context = androidx.glance.LocalContext.current
Text( Text(
text = context.getString(R.string.agenda_no_more_today), text = context.getString(R.string.agenda_no_more_today),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder), maxLines = 1,
style = TextStyle(color = ink.secondary, fontSize = metrics.placeholder),
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxWidth() .fillMaxWidth()
.padding( .padding(start = if (backed) CLASSIC_CARD_H_PAD + STRIPE_INSET else TEXT_INDENT, end = 8.dp, top = 2.dp, bottom = metrics.rowVPad + 2.dp)
start = TEXT_INDENT, .clickable(openDayAction(context, date)),
end = 8.dp, )
top = 2.dp, }
bottom = metrics.rowVPad + 2.dp,
) /** A classic event row: the calendar's colour stripe beside the title and time; a dimmed one fades both. */
.clickable( @Composable
actionStartActivity( private fun ClassicEventRow(row: AgendaRow.Event, data: AgendaWidgetData.Ready, backed: Boolean, ink: ClassicInk, metrics: AgendaMetrics) {
MainActivity.openDateIntent(context, date, CalendarView.Agenda), val context = androidx.glance.LocalContext.current
val event = row.event
val dimmed = row.dimmed(data)
fun accent(dark: Boolean) = eventAccent(event.color, dark, data.soften).let {
if (dimmed) it.copy(alpha = EventDimAlpha) else it
}
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(horizontal = if (backed) CLASSIC_CARD_H_PAD else CLASSIC_ROW_H_PAD, vertical = metrics.rowVPad)
.clickable(actionStartActivity(event.openIntent(context, CalendarView.Agenda))),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = GlanceModifier
.width(STRIPE_WIDTH)
.height(metrics.contentH)
.cornerRadius(3.dp)
.background(themedColor(day = accent(dark = false), night = accent(dark = true))),
) {}
Spacer(GlanceModifier.width(STRIPE_GAP))
Column(modifier = GlanceModifier.defaultWeight()) {
Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1,
style = TextStyle(
color = if (dimmed) ink.title.withAlpha(CLASSIC_DIM_INK) else ink.title,
fontSize = metrics.eventTitle,
textDecoration = glanceDeclinedDecoration(event.isCrossedOut),
), ),
)
Text(
text = eventTimeSummary(context, event, row.date, data.is24Hour),
maxLines = 1,
style = TextStyle(
color = if (dimmed) ink.secondary.withAlpha(CLASSIC_DIM_INK) else ink.secondary,
fontSize = metrics.eventTime,
),
)
}
}
}
/** Space above a day's label on its own backing card, and its rows' inset, clear of the card's corners. */
private val CLASSIC_CARD_TOP_PAD = 8.dp
private val CLASSIC_CARD_H_PAD = 10.dp
/** How strongly a finished event's text shows in the classic style, on any fill. */
private const val CLASSIC_DIM_INK = 0.5f
/** The inverted secondary text's strength, as `onSurfaceVariant` sits below `onSurface`. */
private const val INVERTED_SECONDARY_INK = 0.75f
/** Where each row sits among the rows of its day, from each row's date, for the grouped-card shapes. */
private fun dayPositions(dates: List<LocalDate>): List<Position> = buildList {
var start = 0
for (end in 1..dates.size) {
if (end == dates.size || dates[end] != dates[start]) {
for (i in start until end) add(positionOf(i - start, end - start))
start = end
}
}
}
@Composable
private fun AgendaRowContent(row: AgendaRow, data: AgendaWidgetData.Ready, metrics: AgendaMetrics) {
when (row) {
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
is AgendaRow.Event -> EventRow(
event = row.event,
day = row.date,
fill = row.fill(data),
is24Hour = data.is24Hour,
metrics = metrics,
)
}
}
@Composable
private fun AgendaHeader() {
val context = androidx.glance.LocalContext.current
// Tap the "Upcoming" title to open the app on the user's default view
// (issue #20) — a null target resolves to the default home in the host.
WidgetHeader(
title = context.getString(R.string.widget_agenda_title),
titleAction = actionStartActivity(MainActivity.openViewIntent(context, view = null)),
titleInset = 4.dp,
) {
WidgetHeaderAction(
icon = ImageProvider(R.drawable.ic_widget_add),
contentDescription = context.getString(R.string.widget_new_event),
action = actionStartActivity(MainActivity.openCreateIntent(context, today(systemZone()))),
)
}
}
/** The month name ("November", with its year outside the current one) above a new month's first day. */
@Composable
private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
Text(
text = monthName(date, today),
maxLines = 1,
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = metrics.monthLabel,
fontWeight = FontWeight.Medium,
),
modifier = GlanceModifier
.padding(bottom = 4.dp)
.wallpaperBacking(radius = 8.dp)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
/** A day's weekday and number in a rounded pill, filled for today; opens that day in the app. */
@Composable
private fun DatePill(date: LocalDate, today: LocalDate, cardColor: ColorProvider, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
val isToday = date == today
val container = if (isToday) GlanceTheme.colors.primary else cardColor
val content = if (isToday) GlanceTheme.colors.onPrimary else GlanceTheme.colors.onSurface
val label = agendaDayLabel(context, date, today)
Column(
modifier = GlanceModifier
.width(metrics.pillWidth)
.height(metrics.rowHeight)
.cornerRadius(metrics.pillWidth / 2)
.background(container)
.clickable(openDayAction(context, date))
.semantics { contentDescription = label },
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = weekdayShort(date),
maxLines = 1,
style = TextStyle(color = content, fontSize = metrics.pillWeekday, textAlign = TextAlign.Center),
)
Text(
text = date.day.toString(),
maxLines = 1,
style = TextStyle(
color = content,
fontSize = metrics.pillDay,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
), ),
)
}
}
/** "No more events today" in an anchored, event-less today's card (#35), as tall as the pill beside it. */
@Composable
private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
Box(
modifier = GlanceModifier
.fillMaxWidth()
.height(metrics.rowHeight)
.padding(horizontal = ROW_H_PAD)
.clickable(openDayAction(context, date)),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = context.getString(R.string.agenda_no_more_today),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
)
}
}
/** An event row's light and dark fill: its calendar's container tone, faded when the row is dimmed. */
private data class EventFill(val day: Color, val night: Color, val dimmed: Boolean)
/** Whether this row is drawn faded as a finished event. */
private fun AgendaRow.Event.dimmed(data: AgendaWidgetData.Ready): Boolean =
data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now)
private fun AgendaRow.Event.fill(data: AgendaWidgetData.Ready): EventFill {
val dimmed = dimmed(data)
return EventFill(
day = widgetEventFill(event.color, dark = false, data.soften, dimmed),
night = widgetEventFill(event.color, dark = true, data.soften, dimmed),
dimmed = dimmed,
) )
} }
@@ -412,61 +610,40 @@ private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
private fun EventRow( private fun EventRow(
event: EventInstance, event: EventInstance,
day: LocalDate, day: LocalDate,
dark: Boolean, fill: EventFill,
soften: Boolean,
is24Hour: Boolean, is24Hour: Boolean,
dimmed: Boolean,
metrics: AgendaMetrics, metrics: AgendaMetrics,
) { ) {
val context = androidx.glance.LocalContext.current val context = androidx.glance.LocalContext.current
val title = event.title.ifBlank { context.getString(R.string.event_untitled) } val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
// Glance has no generic alpha modifier, so dim by fading the colour stripe and val titleInk = themedColor(day = widgetEventInk(fill.day, 1f, fill.dimmed), night = widgetEventInk(fill.night, 1f, fill.dimmed))
// dropping both text lines to the lower-emphasis on-surface-variant tone. val timeInk = themedColor(
val stripeColor = eventAccent(event.color, dark, soften).let { day = widgetEventInk(fill.day, SECONDARY_INK_ALPHA, fill.dimmed),
if (dimmed) it.copy(alpha = EventDimAlpha) else it night = widgetEventInk(fill.night, SECONDARY_INK_ALPHA, fill.dimmed),
} )
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface Column(
Row(
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad) .height(metrics.rowHeight)
.clickable( .padding(horizontal = ROW_H_PAD)
actionStartActivity( .clickable(actionStartActivity(event.openIntent(context, CalendarView.Agenda))),
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = CalendarView.Agenda,
),
),
),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box( Text(
modifier = GlanceModifier text = title,
.width(STRIPE_WIDTH) maxLines = 1,
.height(metrics.stripeH) style = TextStyle(
.cornerRadius(3.dp) color = titleInk,
.background(stripeColor), fontSize = metrics.eventTitle,
) {} fontWeight = FontWeight.Medium,
Spacer(GlanceModifier.width(STRIPE_GAP)) textDecoration = glanceDeclinedDecoration(event.isCrossedOut),
Column(modifier = GlanceModifier.defaultWeight()) { ),
Text( )
text = title, Text(
maxLines = 1, text = eventTimeSummary(context, event, day, is24Hour),
style = TextStyle( maxLines = 1,
color = titleColor, style = TextStyle(color = timeInk, fontSize = metrics.eventTime),
fontSize = metrics.eventTitle, )
textDecoration = glanceDeclinedDecoration(event.isDeclined),
),
)
Text(
text = eventTimeSummary(context, event, day, is24Hour),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime),
)
}
} }
} }
@@ -480,47 +657,39 @@ private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
Text( Text(
text = context.getString(resId), text = context.getString(resId),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message),
modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp),
) )
} }
} }
private fun zone(): TimeZone = systemZone()
/** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */ /** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */
private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String { private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
val relative = when (date) { val relative = relativeDayWord(context, date, today)
today -> context.getString(R.string.agenda_header_today) val locale = widgetLocale()
today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
else -> null
}
val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
// Weekday + date in the locale's own field order (compact: no year), rather // Weekday + date in the locale's own field order (compact: no year), rather
// than a hardcoded day-month layout. // than a hardcoded day-month layout.
val formatted = localizedDateFormatter(locale, "EEEdMMM").format(java) val formatted = localizedDateFormatter(locale, if (date.year == today.year) "EEEdMMM" else "EEEdMMMy").format(java)
return if (relative != null) "$relative · $formatted" else formatted return if (relative != null) "$relative · $formatted" else formatted
} }
private fun monthName(date: LocalDate, today: LocalDate): String = formatCalendarTitle(
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1),
locale = widgetLocale(),
currentYear = today.year,
skeleton = "LLLL",
)
private fun weekdayShort(date: LocalDate): String =
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, widgetLocale())
private fun eventTimeSummary( private fun eventTimeSummary(
context: Context, context: Context,
event: EventInstance, event: EventInstance,
day: LocalDate, day: LocalDate,
is24Hour: Boolean, is24Hour: Boolean,
): String { ): String {
val time = when (val label = agendaTimeLabel(event, day, zone())) { val time = widgetTimeLabel(context, event, day, is24Hour)
AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day)
is AgendaTimeLabel.Starts ->
context.getString(R.string.agenda_span_starts, formatTime(label.start, is24Hour))
is AgendaTimeLabel.Ends ->
context.getString(R.string.agenda_span_ends, formatTime(label.end, is24Hour))
is AgendaTimeLabel.Range ->
"${formatTime(label.start, is24Hour)} – ${formatTime(label.end, is24Hour)}"
}
val location = event.location?.takeIf { it.isNotBlank() } val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time return if (location != null) "$time · $location" else time
} }
private fun formatTime(instant: Instant, is24Hour: Boolean): String {
val t = instant.toLocalDateTime(zone()).time
return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
}
@@ -1,44 +1,9 @@
package de.jeanlucmakiola.calendula.widget.agenda package de.jeanlucmakiola.calendula.widget.agenda
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
/** /** Host-facing receiver for the agenda widget; rendering is [AgendaWidget]'s. */
* Host-facing receiver for the agenda widget. Declared in the manifest with the class AgendaWidgetReceiver : CalendulaWidgetReceiver() {
* `appwidget_info_agenda` provider metadata; delegates all rendering to
* [AgendaWidget].
*/
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = AgendaWidget() override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
/** First agenda widget placed — start rolling "today" over at midnight (#228). */
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetRolloverScheduler.sync(context)
}
/**
* Last agenda widget removed. [WidgetRolloverScheduler.sync] cancels only if
* no month widget is left either.
*/
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetRolloverScheduler.sync(context)
}
/**
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
*/
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRolloverScheduler.sync(context)
}
} }
@@ -0,0 +1,179 @@
package de.jeanlucmakiola.calendula.widget.config
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.domain.dateZone
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.PickerSearchField
import de.jeanlucmakiola.calendula.widget.countdown.CountdownMode
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedChoiceRow
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
/** What the countdown widget counts to (#439): the next event, or one picked from a searchable list. */
@Composable
internal fun CountdownOptionsSection(
options: CountdownOptions,
candidates: CountdownCandidates,
onChange: (CountdownOptions) -> Unit,
onLoadCandidates: () -> Unit,
) {
var picking by rememberSaveable { mutableStateOf(false) }
GroupedSectionHeader(stringResource(R.string.widget_config_countdown))
GroupedChoiceRow(
title = stringResource(R.string.widget_config_countdown),
options = CountdownMode.entries,
selected = options.mode,
label = {
stringResource(
when (it) {
CountdownMode.NEXT -> R.string.widget_config_countdown_next
CountdownMode.PINNED -> R.string.widget_config_countdown_pinned
},
)
},
onSelect = { mode ->
onChange(options.copy(mode = mode))
if (mode == CountdownMode.PINNED && options.eventId == null) picking = true
},
position = if (options.mode == CountdownMode.PINNED) Position.Top else Position.Alone,
)
when (options.mode) {
CountdownMode.NEXT -> GroupedHint(stringResource(R.string.widget_config_countdown_next_hint), verticalPadding = 8.dp)
CountdownMode.PINNED -> GroupedRow(
title = stringResource(R.string.widget_config_countdown_event),
summary = options.title ?: stringResource(R.string.widget_config_countdown_event_none),
position = Position.Bottom,
onClick = { picking = true },
)
}
if (picking) {
LaunchedEffect(Unit) { onLoadCandidates() }
CountdownEventPicker(
candidates = candidates,
selectedEventId = options.eventId,
onRetry = onLoadCandidates,
onSelect = { candidate ->
onChange(options.copy(mode = CountdownMode.PINNED, eventId = candidate.event.eventId, title = candidate.event.title))
picking = false
},
onDismiss = { picking = false },
)
}
}
@Composable
private fun CountdownEventPicker(
candidates: CountdownCandidates,
selectedEventId: Long?,
onRetry: () -> Unit,
onSelect: (CountdownCandidate) -> Unit,
onDismiss: () -> Unit,
) {
var query by rememberSaveable { mutableStateOf("") }
FullScreenPicker(
title = stringResource(R.string.widget_config_countdown_event),
onDismiss = onDismiss,
predictiveBack = true,
scrollable = false,
) {
PickerSearchField(
query = query,
onQueryChange = { query = it },
placeholder = stringResource(R.string.widget_config_countdown_search),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
when (candidates) {
CountdownCandidates.Idle, CountdownCandidates.Loading -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
CountdownCandidates.Failed -> Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
PickerMessage(stringResource(R.string.widget_config_countdown_load_failed))
FilledTonalButton(onClick = onRetry, modifier = Modifier.padding(top = 16.dp)) {
Text(stringResource(R.string.state_retry))
}
}
is CountdownCandidates.Ready -> {
val locale = currentLocale()
val matches = remember(candidates, query) {
val needle = query.trim()
if (needle.isEmpty()) candidates.events
else candidates.events.filter { it.event.title.contains(needle, ignoreCase = true) }
}
if (matches.isEmpty()) {
PickerMessage(
stringResource(R.string.widget_config_countdown_empty),
Modifier.fillMaxWidth().padding(24.dp),
)
} else {
val zone = remember { TimeZone.currentSystemDefault() }
val formatter = remember(locale) { localizedDateFormatter(locale, "EEEdMMMy") }
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
itemsIndexed(matches, key = { _, it -> it.event.eventId }) { index, candidate ->
val date = formatter.format(candidate.event.start.toLocalDateTime(candidate.event.dateZone(zone)).date.toJavaLocalDate())
GroupedRow(
title = candidate.event.title.ifBlank { stringResource(R.string.event_untitled) },
summary = if (candidate.repeats) stringResource(R.string.widget_config_countdown_repeats, date) else date,
position = positionOf(index, matches.size),
selected = candidate.event.eventId == selectedEventId,
minHeight = 56.dp,
leading = { CalendarColorChip(candidate.event.color) },
onClick = { onSelect(candidate) },
)
}
}
}
}
}
}
}
@Composable
private fun PickerMessage(text: String, modifier: Modifier = Modifier) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = modifier,
)
}
@@ -0,0 +1,32 @@
package de.jeanlucmakiola.calendula.widget.config
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidgetManager
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetState
/** One of the app's widgets on the home screen, for the settings list. */
data class PlacedWidget(val appWidgetId: Int, val kind: WidgetKind, val appearance: WidgetAppearance)
/**
* Every placed widget, agenda ones first, each in placement order. An id the
* host bound but never laid out (a placement it abandoned without freeing the
* id) has no size yet and is left out, as is one removed while this reads.
*/
suspend fun Context.placedWidgets(): List<PlacedWidget> {
val manager = AppWidgetManager.getInstance(this) ?: return emptyList()
val glanceManager = GlanceAppWidgetManager(this)
return WidgetKind.entries.flatMap { kind ->
manager.getAppWidgetIds(ComponentName(this, kind.receiver)).sorted().mapNotNull { id ->
if (manager.getAppWidgetOptions(id).getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) <= 0) {
return@mapNotNull null
}
val glanceId = runCatching { glanceManager.getGlanceIdBy(id) }.getOrNull() ?: return@mapNotNull null
PlacedWidget(id, kind, widgetState(glanceId).widgetAppearance)
}
}
}
@@ -0,0 +1,60 @@
package de.jeanlucmakiola.calendula.widget.config
import androidx.compose.material3.Checkbox
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource
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.orUnnamed
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.positionOf
/** Which of the app's calendars one widget shows, grouped by account like the drawer's filter. */
@Composable
internal fun WidgetCalendarPicker(
calendars: List<CalendarSource>,
hidden: Set<Long>,
onSetShown: (Long, Boolean) -> Unit,
onDismiss: () -> Unit,
) {
FullScreenPicker(
title = stringResource(R.string.widget_config_calendars),
onDismiss = onDismiss,
predictiveBack = true,
) {
GroupedHint(stringResource(R.string.widget_config_calendars_hint), verticalPadding = 8.dp)
calendars.groupByAccount().forEach { group ->
GroupedSectionHeader(accountGroupTitle(group))
group.calendars.forEachIndexed { index, calendar ->
val shown = calendar.id !in hidden
GroupedRow(
title = calendar.displayName.orUnnamed(),
position = positionOf(index, group.calendars.size),
minHeight = 56.dp,
leading = { CalendarColorChip(calendar.color) },
trailing = { Checkbox(checked = shown, onCheckedChange = { onSetShown(calendar.id, it) }) },
onClick = { onSetShown(calendar.id, !shown) },
)
}
}
}
}
/** "All calendars", or how many of them the widget shows. */
@Composable
internal fun calendarsSummary(calendars: List<CalendarSource>, hidden: Set<Long>): String {
val shown = calendars.count { it.id !in hidden }
return if (shown == calendars.size) {
stringResource(R.string.widget_config_calendars_all)
} else {
pluralStringResource(R.plurals.widget_config_calendars_some, calendars.size, shown, calendars.size)
}
}
@@ -0,0 +1,133 @@
package de.jeanlucmakiola.calendula.widget.config
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.get
import de.jeanlucmakiola.floret.prefs.isDark
import de.jeanlucmakiola.floret.prefs.isPureBlack
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* A widget's config screen (#292): opened by the launcher when the widget is
* placed, from its "Edit widget" action on Android 12+, and from the app's
* widget settings. Shows over the wallpaper, so a transparent widget previews
* against the real home screen.
*/
@AndroidEntryPoint
class WidgetConfigActivity : ComponentActivity() {
@Inject lateinit var settingsPrefs: SettingsPrefs
private val viewModel: WidgetConfigViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appWidgetId = viewModel.appWidgetId
// Backing out of a fresh placement leaves no unconfigured widget behind.
setResult(RESULT_CANCELED, result(appWidgetId))
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
enableEdgeToEdge()
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.events.collect { event ->
when (event) {
WidgetConfigEvent.SAVED -> {
setResult(RESULT_OK, result(appWidgetId))
finish()
}
WidgetConfigEvent.SAVE_FAILED ->
Toast.makeText(this@WidgetConfigActivity, R.string.widget_config_save_failed, Toast.LENGTH_SHORT).show()
}
}
}
}
val fontSettings = settingsPrefs.data.map {
AppFontSettings(
brand = it[SettingsKeys.brandFont],
plain = it[SettingsKeys.plainFont],
brandStamp = it[SettingsKeys.brandFontStamp],
plainStamp = it[SettingsKeys.plainFontStamp],
)
}
// Seeded from MainActivity's mirrored copy, so an app theme override
// shows from the first frame.
val cachedTheme = WindowThemeCache.read(this) ?: ThemeMode.SYSTEM
val cachedDynamicColor = WindowThemeCache.readDynamicColor(this) ?: true
setContent {
val themeMode by settingsPrefs.themeMode.collectAsStateWithLifecycle(cachedTheme)
val dynamicColor by settingsPrefs.dynamicColor.collectAsStateWithLifecycle(cachedDynamicColor)
val fonts by fontSettings.collectAsStateWithLifecycle(initialValue = null)
val context = LocalContext.current
// Nothing until the font choice is known, rather than a frame in the default one.
val typography = remember(fonts, context) {
fonts?.let {
calendulaTypography(
brand = resolveFontFamily(it.brand, FontRole.BRAND, context),
plain = resolveFontFamily(it.plain, FontRole.PLAIN, context),
)
}
} ?: return@setContent
CalendulaTheme(
darkTheme = themeMode.isDark(isSystemInDarkTheme()),
dynamicColor = dynamicColor,
pureBlack = themeMode.isPureBlack,
typography = typography,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
if (state is WidgetConfigUiState.Missing) LaunchedEffect(Unit) { finish() }
WidgetConfigScreen(
state = state,
onClose = ::finish,
onSave = viewModel::save,
onRetry = viewModel::retry,
onAppearance = viewModel::setAppearance,
onContent = viewModel::setContent,
onCalendarShown = viewModel::setCalendarShown,
onCountdown = viewModel::setCountdown,
onLoadCountdownCandidates = viewModel::loadCountdownCandidates,
)
}
}
}
private fun result(appWidgetId: Int) = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
companion object {
/** Opens placed widget [appWidgetId]'s config screen from inside the app. */
fun intent(context: Context, appWidgetId: Int): Intent =
Intent(context, WidgetConfigActivity::class.java)
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
}
@@ -0,0 +1,472 @@
package de.jeanlucmakiola.calendula.widget.config
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
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.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.settings.pastEventDisplayLabelRes
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetOption
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.WidgetTheme
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetContent
import de.jeanlucmakiola.calendula.widget.date.DateWidgetContent
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetContent
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetContent
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetContent
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetContent
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetContent
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.floret.components.GroupedChoiceRow
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview
import de.jeanlucmakiola.floret.glance.WallpaperWindow
import de.jeanlucmakiola.floret.glance.WidgetBackground
import kotlin.math.roundToInt
import de.jeanlucmakiola.floret.components.GroupedSliderRow
import de.jeanlucmakiola.floret.components.InfoPopover
import de.jeanlucmakiola.calendula.widget.AgendaWidgetStyle
import java.text.NumberFormat
import de.jeanlucmakiola.floret.locale.currentLocale
import androidx.compose.runtime.remember
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.Switch
import androidx.compose.foundation.layout.Row
private val PreviewMaxHeight = 280.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun WidgetConfigScreen(
state: WidgetConfigUiState,
onClose: () -> Unit,
onSave: () -> Unit,
onRetry: () -> Unit,
onAppearance: (WidgetAppearance) -> Unit,
onContent: (AgendaWidgetOptions) -> Unit,
onCalendarShown: (Long, Boolean) -> Unit,
onCountdown: (CountdownOptions) -> Unit,
onLoadCountdownCandidates: () -> Unit,
) {
val ready = state as? WidgetConfigUiState.Ready
val surface = MaterialTheme.colorScheme.surface
Column(modifier = Modifier.fillMaxSize()) {
TopAppBar(
title = {
Text(
text = ready?.let { stringResource(it.kind.label) }.orEmpty(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
}
},
actions = {
Button(
onClick = onSave,
enabled = ready != null && !ready.saving,
modifier = Modifier.padding(end = 8.dp),
) {
Text(stringResource(R.string.dialog_save))
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = surface),
)
when (state) {
WidgetConfigUiState.Loading, WidgetConfigUiState.Missing -> Box(
modifier = Modifier.fillMaxSize().background(surface),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
WidgetConfigUiState.Failed -> Column(
modifier = Modifier.fillMaxSize().background(surface).padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.widget_config_load_failed),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
FilledTonalButton(onClick = onRetry) { Text(stringResource(R.string.state_retry)) }
}
is WidgetConfigUiState.Ready -> {
WallpaperWindow(color = surface) {
val maxSize = DpSize(maxWidth, PreviewMaxHeight)
when (val preview = state.preview) {
null -> Box(
modifier = Modifier.padding(vertical = 16.dp).height(maxSize.height / 2),
contentAlignment = Alignment.Center,
) {
if (state.previewFailed) PreviewFailed() else CircularProgressIndicator()
}
else -> GlanceWidgetPreview(
size = state.placedSize,
maxSize = maxSize,
preview,
state.appearance,
state.appTheme,
modifier = Modifier.padding(vertical = 16.dp),
placeholder = { CircularProgressIndicator() },
failure = { PreviewFailed() },
) {
when (preview) {
is WidgetPreviewData.Agenda ->
AgendaWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
is WidgetPreviewData.Month ->
MonthWidgetContent(preview.source, preview.index, state.appearance, state.appTheme)
is WidgetPreviewData.MonthDay -> MonthDayWidgetContent(
preview.data,
preview.index,
selected = null,
appearance = state.appearance,
appTheme = state.appTheme,
preview = true,
)
is WidgetPreviewData.Countdown ->
CountdownWidgetContent(preview.data, state.appearance, state.appTheme)
is WidgetPreviewData.Days -> when (state.kind) {
WidgetKind.TODAY ->
TodayWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
WidgetKind.UPCOMING -> UpcomingWidgetContent(preview.data, state.appearance, state.appTheme)
else -> WeekStripWidgetContent(preview.data, state.appearance, state.appTheme)
}
is WidgetPreviewData.Today -> if (state.kind == WidgetKind.DATE) {
DateWidgetContent(preview.today, state.appearance, state.appTheme)
} else {
QuickAddWidgetContent(preview.today, state.appearance, state.appTheme)
}
}
}
}
}
ConfigOptions(
state = state,
onAppearance = onAppearance,
onContent = onContent,
onCalendarShown = onCalendarShown,
onCountdown = onCountdown,
onLoadCountdownCandidates = onLoadCountdownCandidates,
modifier = Modifier
.weight(1f)
.background(surface),
)
}
}
}
}
@Composable
private fun PreviewFailed() {
Text(
text = stringResource(R.string.widget_config_preview_failed),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier
.background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.large)
.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
@Composable
private fun ConfigOptions(
state: WidgetConfigUiState.Ready,
onAppearance: (WidgetAppearance) -> Unit,
onContent: (AgendaWidgetOptions) -> Unit,
onCalendarShown: (Long, Boolean) -> Unit,
onCountdown: (CountdownOptions) -> Unit,
onLoadCountdownCandidates: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.navigationBarsPadding()
.padding(bottom = 16.dp),
) {
GroupedSectionHeader(stringResource(R.string.settings_section_appearance))
GroupedChoiceRow(
title = stringResource(R.string.widget_config_background),
options = WidgetBackground.entries,
selected = state.appearance.background,
label = { backgroundLabel(it) },
onSelect = { onAppearance(state.appearance.copy(background = it)) },
position = Position.Top,
)
val faded = state.appearance.background != WidgetBackground.TRANSPARENT
val content = state.draft.content
val classic = state.kind == WidgetKind.AGENDA && content.style == AgendaWidgetStyle.CLASSIC
if (faded) {
val opacity = state.appearance.opacity
GroupedSliderRow(
title = stringResource(R.string.widget_config_opacity),
value = opacity,
onValueChange = { onAppearance(state.appearance.copy(opacity = it)) },
position = Position.Middle,
valueLabel = currentLocale().let { locale ->
remember(opacity, locale) { NumberFormat.getPercentInstance(locale).format(opacity) }
},
valueRange = MIN_OPACITY..1f,
steps = OPACITY_STEPS,
titleAccessory = {
InfoPopover(
text = stringResource(
if (classic) R.string.widget_config_readability_hint_classic else R.string.widget_config_readability_hint,
),
contentDescription = stringResource(R.string.widget_config_readability_info),
)
},
)
}
if (faded && classic) {
GroupedRow(
title = stringResource(R.string.widget_config_invert_text),
summary = stringResource(R.string.widget_config_invert_text_summary),
position = Position.Middle,
trailing = {
Switch(checked = content.invertText, onCheckedChange = { onContent(content.copy(invertText = it)) })
},
onClick = { onContent(content.copy(invertText = !content.invertText)) },
)
}
GroupedChoiceRow(
title = stringResource(R.string.settings_theme),
options = WidgetTheme.entries,
selected = state.appearance.theme,
label = { themeLabel(it) },
onSelect = { onAppearance(state.appearance.copy(theme = it)) },
position = Position.Bottom,
)
if (state.kind == WidgetKind.COUNTDOWN) {
CountdownOptionsSection(
options = state.draft.countdown,
candidates = state.candidates,
onChange = onCountdown,
onLoadCandidates = onLoadCountdownCandidates,
)
}
if (state.kind.options.isNotEmpty()) {
ContentOptions(state = state, onChange = onContent, onCalendarShown = onCalendarShown)
}
}
}
@Composable
private fun ContentOptions(
state: WidgetConfigUiState.Ready,
onChange: (AgendaWidgetOptions) -> Unit,
onCalendarShown: (Long, Boolean) -> Unit,
) {
val options = state.draft.content
var showStyle by rememberSaveable { mutableStateOf(false) }
var showRange by rememberSaveable { mutableStateOf(false) }
var showSize by rememberSaveable { mutableStateOf(false) }
var showPast by rememberSaveable { mutableStateOf(false) }
var showCalendars by rememberSaveable { mutableStateOf(false) }
// Rows in WidgetOption order, each shaped by where it lands among the kind's own.
val rows = WidgetOption.entries.filter { it in state.kind.options }
fun position(option: WidgetOption) = positionOf(rows.indexOf(option), rows.size)
GroupedSectionHeader(stringResource(R.string.widget_config_content))
rows.forEach { option ->
when (option) {
WidgetOption.STYLE -> GroupedRow(
title = stringResource(R.string.widget_config_style),
summary = agendaStyleLabel(options.style),
position = position(option),
onClick = { showStyle = true },
)
WidgetOption.RANGE -> GroupedRow(
title = stringResource(R.string.widget_config_range),
summary = agendaRangeLabel(options.range),
position = position(option),
onClick = { showRange = true },
)
WidgetOption.TEXT_SIZE -> GroupedRow(
title = stringResource(R.string.widget_config_text_size),
summary = widgetSizeLabel(options.textSize),
position = position(option),
onClick = { showSize = true },
)
WidgetOption.PAST_DISPLAY -> GroupedRow(
title = stringResource(R.string.settings_past_events),
summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)),
position = position(option),
onClick = { showPast = true },
)
WidgetOption.SHOW_TODAY -> GroupedSwitchRow(
title = stringResource(R.string.settings_agenda_show_today),
checked = options.showToday,
onCheckedChange = { onChange(options.copy(showToday = it)) },
position = position(option),
)
WidgetOption.CALENDARS -> GroupedRow(
title = stringResource(R.string.widget_config_calendars),
summary = calendarsSummary(state.calendars, state.draft.hiddenCalendars),
position = position(option),
onClick = { showCalendars = true },
)
}
}
if (showCalendars) {
WidgetCalendarPicker(
calendars = state.calendars,
hidden = state.draft.hiddenCalendars,
onSetShown = onCalendarShown,
onDismiss = { showCalendars = false },
)
}
if (showStyle) {
OptionPicker(
title = stringResource(R.string.widget_config_style),
predictiveBack = true,
options = AgendaWidgetStyle.entries,
selected = options.style,
label = { agendaStyleLabel(it) },
onSelect = { onChange(options.copy(style = it)) },
onDismiss = { showStyle = false },
)
}
if (showRange) {
AgendaRangePicker(
title = stringResource(R.string.widget_config_range),
description = stringResource(R.string.settings_agenda_widget_range_hint),
selected = options.range,
weekStart = state.weekStart,
onSelect = { onChange(options.copy(range = it)) },
onDismiss = { showRange = false },
)
}
if (showSize) {
OptionPicker(
title = stringResource(R.string.widget_config_text_size),
header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) },
predictiveBack = true,
options = WidgetSize.entries,
selected = options.textSize,
label = { widgetSizeLabel(it) },
summary = { widgetSizeSummary(it) },
onSelect = { onChange(options.copy(textSize = it)) },
onDismiss = { showSize = false },
)
}
if (showPast) {
OptionPicker(
title = stringResource(R.string.settings_past_events),
header = { GroupedHint(stringResource(R.string.settings_past_events_hint), verticalPadding = 8.dp) },
predictiveBack = true,
options = PastEventDisplay.entries,
selected = options.pastDisplay,
label = { stringResource(pastEventDisplayLabelRes(it)) },
onSelect = { onChange(options.copy(pastDisplay = it)) },
onDismiss = { showPast = false },
)
}
}
@Composable
private fun agendaStyleLabel(style: AgendaWidgetStyle): String = stringResource(
when (style) {
AgendaWidgetStyle.CARDS -> R.string.widget_config_style_cards
AgendaWidgetStyle.CLASSIC -> R.string.widget_config_style_classic
},
)
/** The faintest fill the opacity slider allows, and its 10% steps between that and opaque. */
private const val MIN_OPACITY = 0.2f
private const val OPACITY_STEPS = 7
@Composable
internal fun backgroundLabel(background: WidgetBackground): String = stringResource(
when (background) {
WidgetBackground.SOLID -> R.string.widget_config_background_solid
WidgetBackground.TINTED -> R.string.widget_config_background_tinted
WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent
},
)
@Composable
internal fun themeLabel(theme: WidgetTheme): String = stringResource(
when (theme) {
WidgetTheme.FOLLOW_APP -> R.string.widget_config_theme_follow_app
WidgetTheme.LIGHT -> R.string.settings_theme_light
WidgetTheme.DARK -> R.string.settings_theme_dark
},
)
/**
* What a size step does, as a percentage of the smallest one's event text.
* Derived from the widget's own [metricsFor] table rather than hardcoded.
*/
@Composable
private fun widgetSizeSummary(size: WidgetSize): String {
val base = metricsFor(WidgetSize.SMALL).eventTitle.value
val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
return stringResource(R.string.settings_widget_size_summary, percent)
}
@Composable
private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
when (size) {
WidgetSize.SMALL -> R.string.settings_widget_size_small
WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
WidgetSize.LARGE -> R.string.settings_widget_size_large
WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
},
)
Loaded 100 of 295 files, more files were not shown because too many files have changed in this diff. Show more