Compare commits

..
Author SHA1 Message Date
makiolaj d387f3d7bf Merge 2.22.0 work into back preview (#185) 2026-09-30 14:08:04 +02:00
Jean-Luc Makiola 3bead4a21f Merge view morph (#184) 2026-09-30 13:59:10 +02:00
makiolaj 422904c9b9 Merge 2.22.0 work into view morph (#184) 2026-09-30 13:57:59 +02:00
makiolaj 51b9c56c3c Refine view switching and timeline morph (#184) 2026-09-30 13:28:47 +02:00
Jean-Luc Makiola 2af0f44fbe Merge consistent event ordering (#291) 2026-09-30 13:27:27 +02:00
makiolaj 7492888b1a Prioritize multi-day events across calendar views (#291) 2026-09-30 13:26:29 +02:00
Jean-Luc Makiola 17223cf0bb Merge week-number navigation (#197) 2026-09-30 13:24:26 +02:00
makiolaj 21c770afb3 Open week view from month week numbers (#197) 2026-09-30 13:23:37 +02:00
Jean-Luc Makiola aee23dc751 Merge today highlight in month views (#241) 2026-09-30 13:19:51 +02:00
makiolaj 705835a66f Highlight today in the month grid (#241) 2026-09-30 13:19:05 +02:00
Jean-Luc Makiola c2302c033e Merge split day list in Pages month view (#111) 2026-09-30 13:16:27 +02:00
makiolaj 596f76bbaa Open split day list from Pages month style (#111) 2026-09-30 13:15:32 +02:00
Jean-Luc Makiola 85f77f2332 Merge full month agenda in split view (#142) 2026-09-30 13:12:22 +02:00
makiolaj d471255340 Show month agenda by default in split view (#142) 2026-09-30 13:11:21 +02:00
Jean-Luc Makiola dcb1e891f1 Merge localized launcher name (#251) 2026-09-30 13:01:55 +02:00
Jean-Luc Makiola 7bf41fa0f8 Merge relative all-day reminder dates (#308) 2026-09-30 13:00:15 +02:00
Jean-Luc Makiola a23ea81c48 Merge out-of-view indicators (#188) 2026-09-30 12:57:42 +02:00
Jean-Luc Makiola 52aa48fda7 Merge visible time range (#322) 2026-09-30 12:53:29 +02:00
Jean-Luc Makiola 264b1ebbf4 Merge month paging and multi-day view (#193, #336) 2026-09-30 12:50:30 +02:00
makiolaj bd04f7692c Let translators localise the Calendar launcher name (#251) 2026-09-30 12:50:01 +02:00
makiolaj 26de9d369d Fix the zone named in an all-day reminder test comment (#308) 2026-09-30 12:49:49 +02:00
makiolaj 27e5b41038 Say today or tomorrow in all-day reminders too (#308) 2026-09-30 12:49:49 +02:00
makiolaj 2119a36969 Match edge indicators to event colors and clear before reveal (#188) 2026-09-30 12:48:06 +02:00
makiolaj 8d3081a086 Let taps reach events under the edge bar (#188) 2026-09-30 12:48:06 +02:00
makiolaj d1664b28de Tint a day column's edge when events sit out of view (#188) 2026-09-30 12:48:06 +02:00
makiolaj 252689038e Hand the timeline scale row over to visible hours while it's on (#322) 2026-09-30 12:47:50 +02:00
makiolaj 406642050e Hold the fitted hour height after opening (#322) 2026-09-30 12:47:50 +02:00
makiolaj ddf3e203a0 Fit a chosen hour range into the timeline on open (#322) 2026-09-30 12:47:50 +02:00
makiolaj e8ed4c30e1 Add a multi-day view with a configurable day count (#193) 2026-09-30 12:44:22 +02:00
makiolaj d7315caa20 Page the month view with a pager that follows the finger (#336)
Paged and Split now page like the week and day views; Split keeps its
vertical expand drag, now vertical-only beside the pager, and the pane
follows the grid's row count mid-swipe. The old trigger swipe and slide
transition are gone with their last callers.

Closes #336
2026-09-30 12:44:22 +02:00
makiolaj 62ea5ce9d3 Share one top bar across views and smooth the view switches (#184) 2026-09-27 21:12:22 +02:00
makiolaj 254fafe42b Document how the views switch (#184) 2026-09-27 19:31:59 +02:00
makiolaj f9dd7a22b7 Hold the top bar and FAB still across view switches (#184) 2026-09-27 19:28:10 +02:00
makiolaj 7d7d16ca31 Resize timeline columns in place when switching views (#184) 2026-09-27 19:26:24 +02:00
makiolaj 6ed0b30230 Lay timeline columns out through one geometry (#184) 2026-09-27 19:22:09 +02:00
makiolaj 8d3d28c240 Share one focused date across views (#184) 2026-09-27 19:18:13 +02:00
makiolaj f9738b0c23 Put the day view on the paged timeline (#184) 2026-09-27 19:13:47 +02:00
makiolaj 39ca7d869a Carry the timeline hours between views (#184) 2026-09-27 19:10:49 +02:00
makiolaj 0c36ed2a75 Spring the view back even though the gesture job is cancelled (#185) 2026-09-27 12:11:15 +02:00
makiolaj e84378cc5b Preview the previous view on a back gesture (#185) 2026-09-27 12:11:15 +02:00
makiolaj 57dcda5b54 Spring overlays back after a cancelled back gesture (#184) 2026-09-27 12:11:10 +02:00
makiolaj df33eca5f2 Reset the back preview when an overlay reopens (#184) 2026-09-27 12:06:45 +02:00
makiolaj dc841a91e0 Remember the view morph scope across recompositions (#184) 2026-09-27 12:01:41 +02:00
makiolaj f7805cb0b5 Morph events between views instead of fading (#184) 2026-09-27 11:21:32 +02:00
makiolaj 33c61202d9 Add a multi-day view with a configurable day count (#193) 2026-09-25 21:25:17 +02:00
Jean-Luc Makiolaandmakiolaj 9f05a53ed2 Page the day view with a pager that follows the finger (#336) (#337)
Moves the day view onto the same pager as the week view (#131): days follow the finger and snap past half a page or on a fling, the hour gutter stays fixed, neighbouring days are loaded ahead. Opening a day from the month grid lands on that date directly.

Also, from review:
- The per-page state cache (week and day) leaked: dropped entries kept their `stateIn` coroutine and last loaded events alive until the ViewModel was cleared. It is now a shared `PageStateCache` in `common/` that gives each entry its own job and cancels it on eviction.
- The week and day anchor `state` chains had lost their `.catch`, so a settings-flow error crashed instead of showing the failure screen.

Part of #336; the month view follows in its own PR.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/337
2026-09-25 20:49:25 +02:00
makiolaj a2b6793406 Page the month view with a pager that follows the finger (#336)
Paged and Split now page like the week and day views; Split keeps its
vertical expand drag, now vertical-only beside the pager, and the pane
follows the grid's row count mid-swipe. The old trigger swipe and slide
transition are gone with their last callers.

Closes #336
2026-09-25 14:40:04 +02:00
makiolaj eda616b42e Cancel dropped page states and restore the failure catch (#336)
Evicted week and day states kept their sharing coroutine, and their last
loaded events, alive until the view was closed; each now shares on its
own job, cancelled when it is let go.
2026-09-25 14:35:50 +02:00
makiolaj 7c17bf2ff4 Page the day view with a pager that follows the finger (#336)
Same model as the week view: days slide with the finger and snap past half
a page or on a fling, the hour gutter stays put, and the neighbouring days
are loaded ahead. The strip-height blend moves to common so both views
share it.
2026-09-25 13:41:28 +02:00
Jean-Luc Makiola 9ca10fc6ee Page the week view with a pager that follows the finger (#131)
Closes #131
2026-09-25 13:28:03 +02:00
Jean-Luc Makiolaandmakiolaj 994fa010b8 Release v2.21.0 (#332)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 32s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 14m32s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m33s
Minor release rounding up the 2.21.0 milestone.

- Setup makes sure there is a calendar to write to: it offers to create a local calendar or add a syncing account when the device has none, and lists switched-off calendars so they can be turned back on (#287).
- The month grid gives its days more room: smaller week numbers in a gutter measured from the widest number, less chip padding, tighter label tracking (#189, #190, #212, #213).
- A month day shows as many event chips as its week row has height for, instead of a flat three.
- Split view draws only the rows its month spans instead of padding to six (#162).
- Short events show their title in week and day view (#289).
- A fully zoomed-out timeline no longer scrolls by its rounding leftover (#290).
- Backup & restore has loading, failure and empty states instead of a blank page (#304).
- Untitled events and unnamed calendars use the translated placeholder instead of the hardcoded German one, which the calendar editor could also save into the calendar (#321, #329).

Plus the release commit: CHANGELOG section, versionName 2.21.0 / versionCode 22100, and the en-US 22100 changelog. Merging this to main triggers the release pipeline.

Closes #287
Closes #189
Closes #190
Closes #212
Closes #213
Closes #162
Closes #289
Closes #290
Closes #304
Closes #321
Closes #329

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/332
2026-09-24 15:51:06 +02:00
Jean-Luc Makiolaandmakiolaj 5c20c5f6eb Release v2.20.4 (#313)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 13m14s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m26s
Patch release rounding up the three issues in the 2.20.4 milestone.

- An event opened from a reminder or a home-screen widget came back on every
  later launch — an error screen once that event had been deleted. Android
  hands back the intent that started the task each time the activity is
  rebuilt, and the event channels acted on it every time instead of once
  (#309).
- "Today" did nothing in the Agenda once you had scrolled ahead: the agenda is
  browsed by scrolling, not by moving its date, so resetting the date alone
  changed nothing. It now scrolls the list back, and the button stays offered
  while the list is scrolled away (#305).
- The self-hosted F-Droid repo filed the app under "Time" instead of
  "Calendar & Agenda" (#294).

Plus the release commit: CHANGELOG section, versionName 2.20.4 / versionCode
22004, and the en-US 22004 changelog. Merging this to main triggers the release
pipeline.

Closes #309
Closes #305
Closes #294

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/313
2026-09-20 21:16:11 +02:00
Jean-Luc Makiola c4bb7ae0b2 Translations update from Weblate (#300)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/300
2026-09-20 21:11:38 +02:00
Julie 0c985a280c Translated using Weblate (Slovak)
Currently translated at 42.3% (250 of 590 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/sk/
2026-09-17 08:36:18 +00:00
Weblate 17ebb9fd42 Translated using Weblate (Slovak)
Currently translated at 42.3% (250 of 590 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/sk/
2026-09-17 08:36:18 +00:00
James Trivette cadc2b14db Translated using Weblate (Czech)
Currently translated at 100.0% (590 of 590 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-09-15 17:38:23 +00:00
Weblate 1fdca903be Translated using Weblate (Czech)
Currently translated at 100.0% (590 of 590 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-09-15 17:38:23 +00:00
James Trivette 5e7658aa80 Translated using Weblate (Czech)
Currently translated at 100.0% (590 of 590 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-09-15 17:38:23 +00:00
Weblate bea31032fb Translated using Weblate (Czech)
Currently translated at 100.0% (590 of 590 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-09-15 17:38:23 +00:00
James Trivette f7c1dbdbc4 Translated using Weblate (Czech)
Currently translated at 84.9% (501 of 590 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-09-15 17:38:23 +00:00
Jean-Luc Makiolaandmakiolaj a1ed010963 docs(privacy): point the controller contact at support@ (#307)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
The Controller section of `docs/PRIVACY.md` names the address a reader writes
to in order to exercise their rights under Art. 15-21 GDPR. That is a different
kind of mail from a project enquiry, so it now points at
`support@jeanlucmakiola.de` instead of `business@jeanlucmakiola.de`.

Same change is going into Agendula, and the website's own Datenschutzerklärung
already names `support@` in its § 1. `business@` stays reachable — it is just no
longer the published address anywhere.

`updated:` bumped to 2026-09-15, since the published policy changes.

Two notes for review:

- **Target branch.** CONTRIBUTING says to target the release branch named by the
  issue's milestone. The open milestones (2.21.0 and later) have no branches
  yet, so per the same section this targets `main` and says so — retarget it if
  you would rather it waited for one.
- **No changelog entry.** The entries in `CHANGELOG.md` describe what changed
  for the person using the app, and nothing about the app changed here. Happy to
  add one under a new `## [Unreleased]` if you want the contact change to show
  up in the release notes.

`business@` needs to keep accepting mail until this is live on
jeanlucmakiola.de/calendula/privacy and the Play Console listing has caught up.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/307
2026-09-15 16:10:29 +02:00
Jean-Luc Makiola 7be6b7ac31 Release v2.20.3 (#302)
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 57s
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 12m49s
2026-09-12 17:09:15 +02:00
Jean-Luc Makiola 20c20b71ea Release v2.20.2 (#299)
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Failing after 7m20s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
2026-09-12 15:49:04 +02:00
Jean-Luc Makiola 0fb3df0b3a Merge pull request 'Translations update from Weblate' (#262) from jlmakiola_weblate_bot/calendula:weblate-calendula-strings into main
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 15s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
2026-09-12 15:23:25 +02:00
Weblate e10b09a9b5 Translated using Weblate (Arabic)
Currently translated at 100.0% (593 of 593 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-12 02:29:58 +00:00
Sup99999 806a576380 Translated using Weblate (Arabic)
Currently translated at 100.0% (593 of 593 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-12 02:29:58 +00:00
James Trivette 3c365c6792 Translated using Weblate (Czech)
Currently translated at 71.1% (422 of 593 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-09-11 19:34:07 +00:00
Andrei Linchik 4fb4a85ac4 Translated using Weblate (Russian)
Currently translated at 42.4% (252 of 593 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ru/
2026-09-11 19:34:07 +00:00
Benutzer e7312e5d6e Translated using Weblate (Italian)
Currently translated at 98.6% (585 of 593 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-09-11 19:34:07 +00:00
Sup99999 12c2de7ed8 Translated using Weblate (Arabic)
Currently translated at 99.1% (588 of 593 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-11 19:34:07 +00:00
António de Seabra 8a3cc95c66 Translated using Weblate (Portuguese)
Currently translated at 76.8% (456 of 593 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pt/
2026-09-11 19:34:07 +00:00
James Trivette 9c06ffdb48 Translated using Weblate (Czech)
Currently translated at 66.1% (392 of 593 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-09-11 19:34:07 +00:00
James Trivette 870a903015 Translated using Weblate (Czech)
Currently translated at 60.6% (353 of 582 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-09-11 19:34:07 +00:00
Sup99999 b5768e3b17 Translated using Weblate (Arabic)
Currently translated at 99.1% (577 of 582 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-11 19:34:07 +00:00
Dmitri Gabinski 02b33dbf07 Translated using Weblate (Belarusian)
Currently translated at 98.7% (567 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/be/
2026-09-11 19:34:07 +00:00
Jean-Luc Makiola a0c0b875f0 Move the privacy policy into the repo (#293)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/293
2026-09-09 16:23:47 +02:00
makiolaj 512bf8c746 docs: shape the policy as the content entry the site renders
Frontmatter carries the title, description and date, the body starts at
the first section rather than repeating the title as an h1, and the
maintainer note is an HTML comment — a blockquote would have rendered
"edit this in a PR" onto the published privacy page.
2026-09-09 16:14:42 +02:00
makiolaj 04257e8a66 docs: the privacy policy moves into the repo
The policy lived only as prose inside the website's Astro page, so it was
the one user-facing document with no review, no history here, and no way
to change it alongside the code that makes it true.

docs/PRIVACY.md is now that text, transcribed from the published page so
the wording is unchanged, and it becomes the source: the site's build
checks this repository out beside itself and renders this file through an
Astro content collection rather than keeping its own copy. One copy of the
policy exists anywhere, so the two cannot drift.

The README links it from Documentation and gains a Privacy section beside
License. Agendula is being set up the same way.
2026-09-09 16:13:42 +02:00
Jean-Luc Makiolaandmakiolaj c04b2ecee2 Release v2.20.1 (#288)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 31m27s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m22s
Release 2.20.1.

**Fixed**

- "No calendars configured" offered an "Open system calendar settings" button that opened nothing — every screen wired it to the same reload that had just produced the empty state. It now opens the system's account chooser, filtered to the providers that sync calendars ([#239]).
- A device whose calendars are all switched off no longer looks like an empty month. The app only ever checked whether the device had *no* calendars, so calendars hidden at system level fell through to a blank grid with no hint. That case now says so and points at Settings → Calendars. The navigation drawer's calendar list drew the same conflation and is fixed with it ([#239]).

**Beyond the issue**

Two things in here aren't part of #239:

- `fastlane/metadata/android/en-US/changelogs/22001.txt` carries the 2.20.0 notes as well as 2.20.1's. The 2.20.0 Play upload failed on a service-account permission (it lacked "Release to production"; 2.19.4 failed the same way), so Play is still on 2.19.3 and users jump straight to 2.20.1. Left alone they would have seen only the 2.20.1 bugfixes and never heard about the hour grid, copyable event fields or invitation outlines. 402 characters, inside Play's 500 limit. The permission has since been granted, so this release should be the one that lands.
- A merge of `main` into the branch. `main` took 2.20.0 as a squash, so the two had no common ancestor past 2.19.4 and this PR would otherwise have replayed all 74 files of 2.20.0. Resolved to the branch throughout; the tree is byte-for-byte unchanged by it.

Closes #239.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/288
2026-09-07 22:29:03 +02:00
Jean-Luc Makiolaandmakiolaj e10fae0710 Release v2.20.0 (#286)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 5s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 32m28s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 1m21s
Release 2.20.0.

**Added**
- Long-press an event's title, location or description to copy it ([#195]).
- Settings → About lists the open-source projects Calendula ships, with
  copyright holder, licence and a link to each project's source ([#281]).

**Changed**
- Week and day view seat every hour in its own rounded cell instead of drawing a
  separator line across the column; an on-the-hour event fills its cell rather
  than overhanging the seam ([#113]).
- Event titles run to the edge of their chip in all three grid views — no
  ellipsis. RTL keeps it, where clipping would cut the start of the title
  ([#164]).
- Month events show their start time where the chip is wide enough, and event
  times across month, week and day are set in a regular weight ([#219]).
- An invitation you haven't answered is drawn as an outline; declined events
  keep their filled, struck-through chip and now sort last in the month view
  ([#230]).
- The top-bar title falls back to a shorter form instead of being cut off, and
  the bar's trailing edge lines up with the grid under it ([#165]).
- The jump-to-today button carries today's date and rolls over at midnight
  ([#220]).
- The quick-switch pill hides when there is nothing to switch between, and views
  can now be turned off down to a single one ([#150]).

**Fixed**
- A long location wraps and its card grows, instead of scrolling sideways out of
  sight ([#273]).
- The event form capitalizes sentences again; location stays uncapitalized,
  being as often a URL as an address ([#146]).
- A dragged block's floating copy wraps its time and title the way the block
  does, instead of clipping to one line while held ([#267]).

### Release preparation

`versionName`/`versionCode` → 2.20.0/22000, `CHANGELOG.md`'s Unreleased section
moved under a dated 2.20.0 heading, and a hand-written 497-character
`fastlane/metadata/android/en-US/changelogs/22000.txt`.

Six of the merged PRs had no CHANGELOG entry (#146, #150, #195, #230, #267,
#281); they are written up here. The existing #164 entry only described the
month view and did not mention the RTL exception — both corrected.

### Also here

`scripts/check_changelog_lengths.sh`, run in CI next to the reproducible-release
invariant. Play rejects a "What's New" over 500 characters, which would fail the
upload *after* the release had already shipped to F-Droid and Codeberg —
2.19.4 went out at 697. The script walks every locale, fails on the current
`versionCode` and only warns on the already-published files, of which 13 are
over the limit from when the pipeline auto-generated them from `CHANGELOG.md`.

Deviation worth naming: this does not fix those 13. They are published and Play
never re-reads them, so failing on them would paint every PR red for no gain;
`--strict` covers them if they are ever backfilled.

Follow-up filed as #285 — the store listing exists in only 4 of 12 locales and
its graphics still don't qualify for Play.

All twelve issues above are closed by their own PRs; this branch ships them.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/286
2026-09-07 21:25:35 +02:00
184 changed files with 11578 additions and 3430 deletions
+6
View File
@@ -37,6 +37,12 @@ jobs:
- name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh
# Play rejects a "What's New" over 500 characters, which would fail the
# upload after the release had already shipped everywhere else. Cheap, so
# it runs on every PR rather than only on the release merge.
- name: Changelog length invariant
run: bash scripts/check_changelog_lengths.sh
# Decide whether anything that affects the app build changed. Docs, store
# metadata, licence texts and forge housekeeping don't, so those PRs skip
# the SDK + Gradle work below but still report a green `ci`.
+216
View File
@@ -7,6 +7,193 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Pages can open the split agenda.** Swipe up from a full month to reveal the compact grid and month agenda; tap a day to filter it, then swipe down to return. The transition gives haptic feedback ([#111]).
- **Week numbers open their week.** Tap a week number in the month grid to open Week at that row; Back returns to Month ([#197]).
### Changed
- **Split view opens with the month's agenda.** It starts at today in the current month; tapping a day filters the list, and the filter chip restores the full month. Swiping the list moves by day when filtered and by month otherwise ([#142]).
- **Today stands out in the month grid.** Its whole cell has a distinct background; tapping Today briefly expands that cell and nudges its neighbours aside ([#241]).
### Fixed
- **Multi-day events stay above shorter events.** Month and Week bars, Split day lists, and Agenda now place multi-day events before one-day all-day events, then timed events; declined events remain last ([#291]).
## [2.21.0] — 2026-09-23
### Added
- **Setup makes sure you have a calendar to write to.** A fresh install could
finish onboarding and land on an empty grid with nothing saying why — either
because the device had no calendar at all, or because every calendar it had
was switched off. Setup now covers both: it offers to create a calendar on
this device or to add an account that syncs one, and it lists the calendars
that exist but are switched off so you can turn them back on ([#287]).
### Changed
- **The month grid gives its days more room.** Week numbers are set smaller and
their gutter is measured from the widest number it has to seat instead of
holding a fixed 40dp; event chips spend less of their width on padding; and
the labels in both are tracked more tightly, which is worth roughly a
character per chip ([#189], [#190], [#212], [#213]).
- **A month day shows as many events as it has height for.** The limit was a
flat three at every size, so a tall week row threw away space it could have
filled, while a cramped one drew a third chip its band could not hold and put
the "more" marker below the clip, where nothing showed the day held more at
all. It is measured per week row now, and a tablet or a landscape phone seats
more of them.
- **Split view no longer pads a short month out to six rows.** A five-row month
was handing the day pane a blank row and its gap — a sixth of the grid. The
grid now draws only the rows its month spans and the pane grows into the rest
([#162]).
### Fixed
- **Short events show their title in week and day view.** A block needed 20dp
of height before it drew any title at all, which at fit-the-whole-day put the
cutoff around the half hour. The padding now tapers as the block shrinks, so
a short event keeps its name ([#289]).
- **A fully zoomed-out timeline sits still.** Rounding its height up left it as
much as a pixel per hour taller than the viewport those hours are meant to
fill — some 24px of leftover scroll, enough to bounce off Android's
overscroll stretch ([#290]).
- **Backup & restore says what is going on instead of coming up blank.** It
built its rows straight from the calendar list, so loading, a provider error,
and a device with nothing to export all rendered as an empty page with no
buttons on it ([#304]).
- **An event with no title reads in your language.** It was labelled "(Ohne
Titel)" on every screen but the event's own, whatever language the phone was
set to: the German text was chosen before the translated one could be
([#321]).
- **A calendar the system gives no name reads in your language.** The same
cause, in the calendar lists and pickers — and because the calendar editor
starts from that name, saving such a calendar wrote the German text into it
for real ([#329]).
## [2.20.4] — 2026-09-20
### Fixed
- **The app no longer reopens the event you last came in on.** Opening an event
from a reminder or a home-screen widget made the app return to that same
event on every later launch — and show an error screen once the event had
been deleted. Android hands an app the launch it was started with again each
time it is rebuilt, and Calendula was acting on it every time instead of
once ([#309]).
- **"Today" works in the agenda again.** Scrolling a few days ahead and tapping
today did nothing, because the agenda is browsed by scrolling rather than by
moving its date, and the button only moved the date. It now brings the list
back to today, and it stays offered while the list is scrolled away — before,
it was hidden in exactly the situation it is for ([#305]).
- **Calendula is filed under "Calendar & Agenda" in the self-hosted repo.**
Browsing by category in an F-Droid client set to prefer that repo listed the
app under "Time" instead ([#294]).
## [2.20.3] — 2026-09-12
### Fixed
- **A custom agenda range crashed the app in French.** Choosing a custom range
for the agenda — or simply typing a number into that field — closed the app on
any phone set to French. The French translation of the "%d days" label was
missing the wording French uses for counts of two and above, and Android has
nothing to fall back on when that particular form is absent, so every value
except one day was affected ([#297], [#298]).
## [2.20.2] — 2026-09-12
### Fixed
- **Crash reports are filed in English.** Reporting a crash from a phone set to
another language prefilled the issue — title and body — in that language, so
a French crash arrived on Codeberg as a French issue ([#297], [#298]). The
text that is *posted* is now English for everyone; the dialog that asks you
to send it, and everything else you see in the app, is still translated.
## [2.20.1] — 2026-09-07
### Fixed
- **An empty calendar now explains itself.** "No calendars configured" offered
an "Open system calendar settings" button that opened nothing — every screen
wired it to the same reload that had just produced the empty state. It now
opens the system's account chooser, filtered to the providers that sync
calendars ([#239]).
- **Calendars that are all switched off no longer look like an empty month.**
When every calendar on the device is hidden at system level there is nothing
to draw, but the app said nothing at all: it only ever checked whether the
device had *no* calendars, so a device that had them and hid them fell
through to a blank grid with no hint. That case now says so, and points at
Settings → Calendars — the one screen that can switch a calendar back on. The
navigation drawer's calendar list drew the same conflation and is fixed with
it ([#239]).
## [2.20.0] — 2026-09-07
### Added
- **Long-press to copy.** Holding down the title, the location or the
description on an event puts that whole field on the clipboard. Tapping still
does what it always did — the location opens maps, a link in the description
opens — and the times, calendar, repeat rule and guests stay uncopyable. Below
Android 13 a short confirmation appears; from Android 13 on the system shows
its own clipboard chip ([#195]).
- **The open-source projects Calendula ships** are now listed under Settings →
About, each with its copyright holder and licence, and each row tapping
through to that project's source ([#281]).
### Changed
- Event titles no longer end in an ellipsis. The "…" took the width of a couple
of characters and told you nothing you couldn't already see, so the title now
simply runs to the edge of its chip in the month, week and day views — a few
more letters per event, which is often the difference between two events you
can tell apart and two you can't. Right-to-left languages keep the ellipsis,
where clipping would cut the beginning of a title rather than its end
([#164]).
- Month-view events now show their start time before the title, where the chip
is wide enough to keep the title readable — in landscape, on a foldable and on
a tablet, and never at the cost of a narrow phone column. All-day events show
no time, and neither does a bar carried in from the previous week, whose start
is not in that row. Event times across the month, week and day views are also
set in a regular weight against the title's, so a time reads as a time rather
than as part of the name next to it ([#219]).
- **The hour lines in week and day view are now an hour grid.** The setting
that drew a faint separator line at each hour now seats every hour in its own
rounded cell, so an hour boundary reads as the seam between two surfaces
rather than a line drawn across one — the same negative space that separates
the month grid's days and the week's columns. An event that starts on the hour
fills its cell instead of overhanging the seam, and one that runs past
midnight still meets the edge of its column ([#113]).
- **An invitation you haven't answered now reads as an outline.** An event still
waiting on your reply is drawn with the calendar's colour on its border and
its title and nothing behind it, so it stands apart from the ones you have
accepted — on the month chips, the all-day bars and the week and day blocks
alike. Declined events keep their filled, struck-through chip and now sort
last in the month view as well, so a day with more events than fit drops the
ones you said no to first ([#230]).
- The calendar titles now shorten instead of being cut off. When the full month
name doesn't fit the top bar, the month and week views fall back to its
three-letter form and the day view drops the weekday, rather than trailing off
mid-word at large font sizes. The view switcher, the title and the agenda's
range bar also line up with the grid underneath them ([#165]).
- The jump-to-today button in the toolbar now shows today's date. It drew a
generic calendar icon, which told you nothing you didn't already know from
tapping it; it now carries the current day number in an outlined box, so the
bar says what day it is as well as taking you there. It rolls over at midnight
on its own ([#220]).
- The quick-switch pill disappears when there is nothing left to switch between,
and you can now turn calendar views off all the way down to a single one — the
setting used to hold two open purely to keep the pill fed ([#150]).
### Fixed
- **A long location no longer runs off the edge of its field.** The location on
the edit screen sat on one line, so a full postal address or a long meeting
link scrolled sideways out of sight as you typed it. It now wraps and the card
grows to fit, with the pin and the contacts button staying level with the first
line at any font size. A multi-line address you paste in is joined with commas
— the way one picked from your contacts always has been — instead of running
together into a single word ([#273]).
- **The event form capitalizes sentences again.** The title, description and
guest fields had stopped starting a sentence with a capital letter; they
follow the keyboard's usual behaviour once more. The location is deliberately
left alone, since it is as often a URL as an address ([#146]).
- **A block you pick up no longer loses its second line.** Dragging an event
whose time or title wrapped to two lines clipped the floating copy to one line
for as long as you held it, and it snapped back on drop. The copy now measures
its text exactly as the block does ([#267]).
## [2.19.4] — 2026-09-01
### Fixed
@@ -1560,6 +1747,8 @@ automatically, with zero telemetry and no internet permission.
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
[#164]: https://codeberg.org/jlmakiola/calendula/issues/164
[#165]: https://codeberg.org/jlmakiola/calendula/issues/165
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
@@ -1569,5 +1758,32 @@ automatically, with zero telemetry and no internet permission.
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
[#219]: https://codeberg.org/jlmakiola/calendula/issues/219
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253
[#273]: https://codeberg.org/jlmakiola/calendula/issues/273
[#113]: https://codeberg.org/jlmakiola/calendula/issues/113
[#220]: https://codeberg.org/jlmakiola/calendula/issues/220
[#195]: https://codeberg.org/jlmakiola/calendula/issues/195
[#281]: https://codeberg.org/jlmakiola/calendula/issues/281
[#230]: https://codeberg.org/jlmakiola/calendula/issues/230
[#150]: https://codeberg.org/jlmakiola/calendula/issues/150
[#146]: https://codeberg.org/jlmakiola/calendula/issues/146
[#267]: https://codeberg.org/jlmakiola/calendula/issues/267
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309
[#305]: https://codeberg.org/jlmakiola/calendula/issues/305
[#294]: https://codeberg.org/jlmakiola/calendula/issues/294
[#287]: https://codeberg.org/jlmakiola/calendula/issues/287
[#189]: https://codeberg.org/jlmakiola/calendula/issues/189
[#190]: https://codeberg.org/jlmakiola/calendula/issues/190
[#212]: https://codeberg.org/jlmakiola/calendula/issues/212
[#213]: https://codeberg.org/jlmakiola/calendula/issues/213
[#162]: https://codeberg.org/jlmakiola/calendula/issues/162
[#289]: https://codeberg.org/jlmakiola/calendula/issues/289
[#290]: https://codeberg.org/jlmakiola/calendula/issues/290
[#304]: https://codeberg.org/jlmakiola/calendula/issues/304
[#321]: https://codeberg.org/jlmakiola/calendula/issues/321
[#329]: https://codeberg.org/jlmakiola/calendula/issues/329
+12
View File
@@ -157,6 +157,8 @@ reinstalling. Google Play is the exception — see above.</sub>
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
- **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next
- **[Privacy policy](docs/PRIVACY.md)** — the source of the page published at
[jeanlucmakiola.de/calendula/privacy](https://jeanlucmakiola.de/calendula/privacy)
## 🤝 Contributing
@@ -187,6 +189,16 @@ No coding needed — register on the Weblate server, pick (or request) a languag
and translate the strings in your browser. You can also reach this link in the
app from the top of **Settings → App language**.
## 🔒 Privacy
No analytics, no tracking, no advertising, no crash-reporting SDK, no third-party
services — and **no internet permission at all**, so the app is technically
incapable of transmitting anything anywhere. Your events stay in Android's own
calendar store, where they already were.
**→ [Privacy policy](https://jeanlucmakiola.de/calendula/privacy)** — written in
[`docs/PRIVACY.md`](docs/PRIVACY.md), which is what that page renders.
## 📜 License
[MIT](LICENSE) — Jean-Luc Makiola, 2026
+12 -8
View File
@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21904
versionName = "2.19.4"
versionCode = 22100
versionName = "2.21.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -113,12 +113,16 @@ android {
lint {
// Community translations are expected to be partial — a missing string
// falls back to the English base at runtime — so don't fail the build on
// it. Likewise a translated <plurals> may not fill every CLDR quantity
// form its locale defines (e.g. Arabic needs "zero"); the missing form
// falls back to "other" at runtime, so MissingQuantity is informational
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
// check_translations.py guards the same invariants with clearer,
// translator-facing messages.
// it. A translated <plurals> may likewise skip a CLDR quantity form its
// locale defines (e.g. Arabic "zero"): Android falls back to "other" for
// any form it cannot find, so MissingQuantity is informational too.
// What a translation must NOT skip is "other" itself — nothing falls back
// for that one, not even the base locale, so it throws
// Resources$NotFoundException at runtime (Codeberg #297/#298).
// MissingQuantity doesn't tell the two cases apart, so that invariant is
// enforced by scripts/check_translations.py instead, with a clearer
// translator-facing message. Stale/extra keys (ExtraTranslation) stay
// fatal.
informational += listOf("MissingTranslation", "MissingQuantity")
}
@@ -32,13 +32,15 @@ class CalendulaApp : Application() {
// respecting, on-device; the user submits the report by hand). The
// capture/loop-detection/report machinery lives in floret-kit's
// core-crash; only the app label + issue-tracker URLs are app-specific.
// The prefilled issue's own title and body are the kit's, in English:
// they are posted to the tracker, so they don't follow the device
// language the way the dialog around them does.
CrashReporter.install(
this,
CrashConfig(
appLabel = getString(R.string.app_name),
newIssueUrl = getString(R.string.report_issue_url),
chooseIssueUrl = getString(R.string.report_issue_choose_url),
issueTitle = getString(R.string.crash_report_issue_title),
),
)
reconcileAutoBackup()
@@ -31,7 +31,8 @@ import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
@@ -113,11 +114,26 @@ class MainActivity : AppCompatActivity() {
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES
applyEdgeToEdge()
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
// Android hands the intent that started the task back to onCreate every
// time the activity is recreated — on a rotation, or on a restart after
// the system reclaimed the process. Re-running a launch that opens one
// specific event is what made a single reminder or widget tap re-open
// that event on every later launch, an error screen once the event had
// been deleted (#309); the user left it long ago, and the activity
// restores where they actually were along with its saved state.
//
// Only those channels are held back. An .ics import or a prefilled
// create form keeps no saved state of its own, so re-reading the intent
// is what carries one through a recreation.
val replaying = savedInstanceState != null || intent.isRelaunch()
if (!replaying) {
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requestedEditKey = intent.editEventKeyOrNull()
}
requestedNav = intent.navRequestOrNull()
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
requestedImportUri = intent.importUriOrNull()
requestedInsert = intent.insertRequestOrNull()
requestedEditKey = intent.editEventKeyOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent {
// One activity-scoped SettingsViewModel drives both the theme here
@@ -154,6 +170,7 @@ class MainActivity : AppCompatActivity() {
// weeks and switching between the week and day view.
val timelineZoom = rememberTimelineZoom(
stored = settings.timelineScale,
hours = settings.timelineHours,
onPersist = settingsViewModel::setTimelineScale,
)
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
@@ -171,8 +188,9 @@ class MainActivity : AppCompatActivity() {
Box(modifier = Modifier.fillMaxSize()) {
CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour,
LocalShowHourLines provides settings.showHourLines,
LocalShowHourGrid provides settings.showHourGrid,
LocalTimelineZoom provides timelineZoom,
LocalMultiDayCount provides settings.multiDayCount,
LocalSoftenColors provides settings.softenColors,
) {
RootScreen(
@@ -242,6 +260,16 @@ class MainActivity : AppCompatActivity() {
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
}
/**
* Whether this launch is the task being resumed rather than a fresh
* delivery — Android sets the flag when an activity is started from the
* recents list, where the intent it carries is the one the task was started
* with however long ago. `onNewIntent` is the channel a real new request
* arrives on while the task lives, so nothing is lost by ignoring these.
*/
private fun Intent.isRelaunch(): Boolean =
flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
/**
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
@@ -1,22 +1,35 @@
package de.jeanlucmakiola.calendula.data.calendar
/**
* Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
* `EVENT_COLOR`. The named entries exist for callers that need one specific
* hue (the managed special-dates calendars), so they can't drift from the
* swatches offered in the colour picker.
* The colours offered when creating or editing a calendar; ARGB ints for a raw
* `CALENDAR_COLOR` / `EVENT_COLOR`.
*
* Hues rather than final fills: every surface that draws a calendar colour runs
* it through the app's own tone pass first, so these are chosen to stay
* distinguishable after it. Shared with Agendula, whose list palette these are —
* the two apps are the same family and a calendar and a task list picked from
* different-looking sets read as different products.
*
* The named entries exist for callers that need one specific hue (the managed
* special-dates calendars), so they can't drift from the swatches on offer.
*/
object CalendarColorPalette {
val Red = 0xFFD50000.toInt()
val Orange = 0xFFE67C00.toInt()
val Amber = 0xFFF6BF26.toInt()
val Green = 0xFF33B679.toInt()
val DarkGreen = 0xFF0B8043.toInt()
val Blue = 0xFF039BE5.toInt()
val Indigo = 0xFF3F51B5.toInt()
val Purple = 0xFF8E24AA.toInt()
val Graphite = 0xFF616161.toInt()
val Mauve = 0xFF7A5C6B.toInt()
val Red = 0xFFD7484A.toInt()
val Orange = 0xFFE8743B.toInt()
val Amber = 0xFFE0A32E.toInt()
val Olive = 0xFF7CA83E.toInt()
val Green = 0xFF35A06A.toInt()
val Teal = 0xFF19938C.toInt()
val Cyan = 0xFF2A9BC4.toInt()
val Blue = 0xFF3C74C8.toInt()
val Indigo = 0xFF6A5CC0.toInt()
val Purple = 0xFF9455B8.toInt()
val Pink = 0xFFC94F8E.toInt()
/** The full palette, in swatch-row order. */
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
/** The full palette, in swatch-grid order. */
val all: List<Int> = listOf(
Mauve, Red, Orange, Amber, Olive, Green,
Teal, Cyan, Blue, Indigo, Purple, Pink,
)
}
@@ -376,7 +376,7 @@ class AndroidCalendarDataSource @Inject constructor(
.build()
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val name = displayName.trim()
val values = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
@@ -400,7 +400,7 @@ class AndroidCalendarDataSource @Inject constructor(
}
override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) {
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val name = displayName.trim()
val values = ContentValues().apply {
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
put(CalendarContract.Calendars.NAME, name)
@@ -464,7 +464,7 @@ class AndroidCalendarDataSource @Inject constructor(
PackageManager.PERMISSION_GRANTED
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val name = displayName.trim()
val values = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
@@ -8,8 +8,9 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
return CalendarSource(
id = getLong(CalendarProjection.IDX_ID),
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
?: Fallbacks.UNNAMED_CALENDAR,
// Blank when the provider row has no name: the placeholder is a display
// string and belongs to the UI, where it can be localised (#329).
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME).orEmpty(),
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
accountType = accountType,
color = getInt(CalendarProjection.IDX_COLOR),
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import android.provider.CalendarContract
import android.util.Log
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse
private const val TAG = "InstanceMapper"
@@ -20,8 +21,9 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
return null
}
val rawTitle = getString(InstanceProjection.IDX_TITLE)
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
// Left blank when the event has no title: the placeholder is a display
// string, so it belongs to the UI layer where it can be localised (#321).
val title = getString(InstanceProjection.IDX_TITLE).orEmpty()
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
@@ -39,7 +41,17 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
color = color,
location = getString(InstanceProjection.IDX_LOCATION),
isDeclined = getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS) ==
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)),
)
}
/**
* `SELF_ATTENDEE_STATUS` as the calendar surfaces read it: a tentative "maybe"
* counts as going, and everything that is not an open or refused invitation —
* your own events included — falls through to [EventResponse.Going].
*/
internal fun mapEventResponse(raw: Int): EventResponse = when (raw) {
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED -> EventResponse.Declined
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> EventResponse.Invited
else -> EventResponse.Going
}
@@ -312,8 +312,3 @@ internal object ReminderProjection {
const val IDX_MINUTES = 0
const val IDX_METHOD = 1
}
internal object Fallbacks {
const val UNNAMED_CALENDAR = "(Unbenannter Kalender)"
const val UNTITLED_EVENT = "(Ohne Titel)"
}
@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
@@ -22,8 +21,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
}.coerceAtLeast(dtStart)
val rawTitle = getString(SearchProjection.IDX_TITLE)
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
val title = getString(SearchProjection.IDX_TITLE).orEmpty()
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
getInt(SearchProjection.IDX_CALENDAR_COLOR)
} else {
@@ -43,7 +41,6 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
location = getString(SearchProjection.IDX_LOCATION),
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) ==
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)),
)
}
@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale
import de.jeanlucmakiola.calendula.ui.common.storageValue
@@ -212,14 +213,15 @@ class SettingsPrefs @Inject constructor(
}
/**
* Whether the week/day timeline draws a faint separator line at each hour
* (v2.11). Defaults to OFF — the historical clean look; users opt in.
* Whether the week/day timeline seats each hour in its own cell (v2.11, a
* separator line until v2.20). Defaults to OFF — the historical flat column;
* users opt in. The stored key keeps its original name.
*/
val showHourLines: Flow<Boolean> = store.data.map { prefs ->
val showHourGrid: Flow<Boolean> = store.data.map { prefs ->
prefs[SHOW_HOUR_LINES_KEY] ?: false
}
suspend fun setShowHourLines(enabled: Boolean) {
suspend fun setShowHourGrid(enabled: Boolean) {
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
}
@@ -286,6 +288,36 @@ class SettingsPrefs @Inject constructor(
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
}
/** How many days the multi-day view shows side by side (#193). */
val multiDayCount: Flow<Int> = store.data.map { prefs ->
(prefs[MULTI_DAY_COUNT_KEY] ?: DEFAULT_MULTI_DAY_COUNT).coerceIn(MULTI_DAY_COUNTS)
}
suspend fun setMultiDayCount(count: Int) {
store.edit { it[MULTI_DAY_COUNT_KEY] = count.coerceIn(MULTI_DAY_COUNTS) }
}
/**
* The hours the day, week and multi-day timelines fit to the screen when they
* open (#322). Off by default; the hours survive switching it off.
*/
val timelineHours: Flow<TimelineHours> = store.data.map { prefs ->
TimelineHours.of(
enabled = prefs[TIMELINE_HOURS_ENABLED_KEY] ?: false,
startHour = prefs[TIMELINE_HOURS_START_KEY] ?: TimelineHours.DEFAULT_START_HOUR,
endHour = prefs[TIMELINE_HOURS_END_KEY] ?: TimelineHours.DEFAULT_END_HOUR,
)
}
suspend fun setTimelineHours(hours: TimelineHours) {
val valid = TimelineHours.of(hours.enabled, hours.startHour, hours.endHour)
store.edit {
it[TIMELINE_HOURS_ENABLED_KEY] = valid.enabled
it[TIMELINE_HOURS_START_KEY] = valid.startHour
it[TIMELINE_HOURS_END_KEY] = valid.endHour
}
}
/**
* Where the jump-to-today control lives (issue #60). Default OFF — the
* historical layout, where it's an extended FAB that fades in above the "+"
@@ -585,6 +617,24 @@ class SettingsPrefs @Inject constructor(
}
}
/** Whether the wizard's calendar step has been answered (or skipped). */
val onboardingCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
prefs[ONBOARDING_CALENDARS_KEY] ?: false
}
suspend fun setOnboardingCalendarsDone(done: Boolean = true) {
store.edit { it[ONBOARDING_CALENDARS_KEY] = done }
}
/** Whether the wizard's hidden-calendars step has been answered (or skipped). */
val onboardingHiddenCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
prefs[ONBOARDING_HIDDEN_KEY] ?: false
}
suspend fun setOnboardingHiddenCalendarsDone(done: Boolean = true) {
store.edit { it[ONBOARDING_HIDDEN_KEY] = done }
}
/** Whether the wizard's backup step has been answered (or skipped). */
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
prefs[ONBOARDING_BACKUP_KEY] ?: false
@@ -929,10 +979,20 @@ class SettingsPrefs @Inject constructor(
if (view in config.enabled) view.name else "!${view.name}"
}
/** Keep the given order (de-duplicated), then append any views it omits. */
/** Keep stored order; insert the new multi-day view after Week on upgrades. */
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
val ordered = seen.distinct()
return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered }
val ordered = seen.distinct().toMutableList()
IMPLEMENTED_VIEWS.forEach { view ->
if (view !in ordered) {
val index = if (view == CalendarView.MultiDay && CalendarView.Week in ordered) {
ordered.indexOf(CalendarView.Week) + 1
} else {
ordered.size
}
ordered.add(index, view)
}
}
return ordered
}
companion object {
@@ -976,6 +1036,10 @@ class SettingsPrefs @Inject constructor(
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
internal val ONBOARDING_CALENDARS_KEY =
booleanPreferencesKey("onboarding_calendars_done")
internal val ONBOARDING_HIDDEN_KEY =
booleanPreferencesKey("onboarding_hidden_calendars_done")
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
internal val ONBOARDING_MONTH_STYLE_KEY =
@@ -994,8 +1058,14 @@ class SettingsPrefs @Inject constructor(
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
private const val MINUTES_PER_DAY = 1_440
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
internal val MULTI_DAY_COUNT_KEY = intPreferencesKey("multi_day_count")
internal val TIMELINE_HOURS_ENABLED_KEY = booleanPreferencesKey("timeline_hours_enabled")
internal val TIMELINE_HOURS_START_KEY = intPreferencesKey("timeline_hours_start")
internal val TIMELINE_HOURS_END_KEY = intPreferencesKey("timeline_hours_end")
/** Default snooze delay for the notification "Snooze" action. */
const val DEFAULT_SNOOZE_MINUTES = 10
const val DEFAULT_MULTI_DAY_COUNT = 3
val MULTI_DAY_COUNTS = 3..6
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
stringPreferencesKey("per_calendar_reminder_override")
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
@@ -88,6 +88,7 @@ class ReminderNotifier @Inject constructor(
is24Hour = is24Hour,
today = Instant.now().atZone(zone).toLocalDate(),
firstDayOfWeek = firstDayOfWeek,
todayLabel = context.getString(R.string.reminder_day_today),
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
)
@@ -27,12 +27,12 @@ import java.util.Locale
* alone would be ambiguous)
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
* already unambiguous)
* - all-day, one day: "11 Jun 2026"
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
* - all-day, one day: "Tomorrow" / "Thu" / "11 Jun 2026" (same relative rules,
* plus [todayLabel] — no time range to lean on)
* - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end)
*
* All-day instances already carry an explicit date, so they never gain a
* relative prefix. They store UTC midnights with an exclusive end, so they are
* read in UTC and the end day is the last *covered* day.
* All-day instances store UTC midnights with an exclusive end, so they are read
* in UTC and the end day is the last *covered* day (issue #308).
*/
fun reminderTimeText(
beginMillis: Long,
@@ -43,6 +43,7 @@ fun reminderTimeText(
is24Hour: Boolean,
today: LocalDate,
firstDayOfWeek: DayOfWeek,
todayLabel: String,
tomorrowLabel: String,
yesterdayLabel: String,
): String {
@@ -53,11 +54,16 @@ fun reminderTimeText(
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
.minusDays(1)
.coerceAtLeast(firstDay)
return if (lastDay == firstDay) {
dateFormat.format(firstDay)
} else {
dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay)
}
val start = relativeDayPrefix(
day = firstDay,
today = today,
firstDayOfWeek = firstDayOfWeek,
locale = locale,
dateFormat = dateFormat,
tomorrowLabel = tomorrowLabel,
yesterdayLabel = yesterdayLabel,
) ?: todayLabel
return if (lastDay == firstDay) start else start + RANGE + dateFormat.format(lastDay)
}
val timeFormat = timeOfDayFormatter(is24Hour, locale)
@@ -46,3 +46,19 @@ fun hasSystemHiddenCalendars(
calendars: List<CalendarSource>,
pendingDisabledIds: Set<Long>,
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
/**
* The failure a calendar list implies, or null when there is something to show.
* Distinguishes "the device has no calendars" from "every one of them is
* switched off" (#239) — both leave the grid empty, but only the second is
* fixed from Settings → Calendars.
*
* The filter sheet's own hidden set is deliberately not folded in: emptying it
* is a deliberate act taken moments earlier, in a sheet that is still one tap
* away.
*/
fun calendarListFailure(calendars: List<CalendarSource>): FailureReason? = when {
calendars.isEmpty() -> FailureReason.NoCalendarsConfigured
calendars.none { it.isVisibleInSystem } -> FailureReason.AllCalendarsHidden
else -> null
}
@@ -0,0 +1,18 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.TimeZone
/** Lower values appear first in lists and get the upper visible lanes in grids. */
fun EventInstance.displayPriority(zone: TimeZone): Int = when {
isDeclined -> 3
spansMultipleDays(zone) -> 0
isAllDay -> 1
else -> 2
}
/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */
fun eventDisplayComparator(zone: TimeZone): Comparator<EventInstance> =
compareBy<EventInstance> { it.displayPriority(zone) }
.thenBy { it.start }
.thenBy { it.title }
.thenBy { it.instanceId }
@@ -68,14 +68,35 @@ data class EventInstance(
*/
val isRecurring: Boolean = false,
/**
* This device user answered "no" to the invitation
* (`Events.SELF_ATTENDEE_STATUS`). The event stays on the calendar — it is
* still an appointment someone expects an answer about — but every surface
* strikes it through, and it plans no reminders (#180).
* This device user's own answer to the invitation, as far as the grids care
* (#180, #230).
*/
val isDeclined: Boolean = false,
val response: EventResponse = EventResponse.Going,
)
/**
* How this device user stands towards an event's invitation
* (`Events.SELF_ATTENDEE_STATUS`), reduced to the three cases the calendar
* surfaces draw differently (#230).
*/
enum class EventResponse {
/** Your own event, or one you accepted — including a tentative "maybe". */
Going,
/** Invited, no answer given yet: drawn as an outline so it reads as still open. */
Invited,
/**
* You answered "no". The event stays on the calendar — it is still an
* appointment someone expects an answer about — but every surface strikes it
* through, and it plans no reminders (#180).
*/
Declined,
}
/** Shorthand for the declined case, which most surfaces test on its own (#180). */
val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined
/**
* Whether this event has finished relative to [now] — its end is at or before
* the current instant. An in-progress event (already started but not yet ended)
@@ -231,6 +252,14 @@ enum class RecurringWriteScope {
enum class FailureReason {
PermissionRevoked,
NoCalendarsConfigured,
AllCalendarsHidden,
/**
* Calendars exist and are switched on, but none can receive an event: every
* one is read-only, app-managed, or not synced to this device. Distinct from
* [AllCalendarsHidden], which a visibility switch fixes.
*/
NoImportTarget,
ProviderUnavailable,
EventNotFound,
Unknown,
@@ -166,14 +166,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
}
when {
component != null -> {
// A component whose END line never arrives ends at the next
// one's BEGIN, not at that one's END: reading to the far END
// would fold two events into one body, where the later
// properties overwrite the earlier and one event is lost.
val end = indexOfEnd(lines, i + 1, component)
val body = indexOfNextComponent(lines, i + 1, end)
parseComponent(
body = lines.subList(i + 1, end),
body = lines.subList(i + 1, body),
fileCalendarName = calendarName,
warnings = warnings,
isTask = component == "VTODO",
)?.let(events::add)
i = end + 1
i = if (body < end) body else end + 1
}
line.isBegin("VTIMEZONE") -> {
// Skipped wholesale; TZIDs resolve against the OS tz database.
@@ -445,6 +450,23 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
}
/** Index of the matching `END:<component>` at/after [from], or list end. */
/**
* Where an unterminated component's body has to stop: the next top-level
* `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A
* nested `VALARM` is part of the body and is not a boundary.
*/
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int {
var i = from
while (i < end) {
val line = parseContentLine(lines[i])
if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) {
return i
}
i++
}
return end
}
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
var i = from
while (i < lines.size) {
@@ -1,24 +1,40 @@
package de.jeanlucmakiola.calendula.ui
import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.SeekableTransitionState
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.ui.common.screen
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.domain.EventForm
@@ -26,17 +42,38 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.calendula.ui.common.BelowTopBarClip
import de.jeanlucmakiola.calendula.ui.common.StayingPut
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFab
import de.jeanlucmakiola.calendula.ui.common.CalendarTopBar
import de.jeanlucmakiola.calendula.ui.common.ChromeHost
import de.jeanlucmakiola.calendula.ui.common.ChromeSlot
import de.jeanlucmakiola.calendula.ui.common.LocalChromeSlot
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBackExit
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScroll
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.ViewFocus
import de.jeanlucmakiola.calendula.ui.common.ViewFocusSaver
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
import de.jeanlucmakiola.calendula.ui.common.TimelineScrollMemory
import de.jeanlucmakiola.calendula.ui.common.ViewMorphScope
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform
import de.jeanlucmakiola.calendula.ui.common.drillToDay
import de.jeanlucmakiola.calendula.ui.common.drillToWeek
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.selectView
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
import de.jeanlucmakiola.calendula.ui.day.DayScreen
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
@@ -44,16 +81,21 @@ import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
import de.jeanlucmakiola.calendula.ui.week.TimelineScreen
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock
/**
* Holds the top-level view back stack (spec M1) and swaps between the calendar
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
* pill in their top bars writes back here via [onSelectView].
* screens. Each screen owns its own ViewModel; they all open on the one focused
* date held here ([ViewFocus]), and the view-switcher pill in their top bars
* writes back here via [onSelectView].
*
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
* A lateral switch (pill / drawer) builds a visit history so back retraces it
@@ -70,6 +112,7 @@ import kotlin.time.Clock
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
* widget's view.)
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun CalendarHost(
modifier: Modifier = Modifier,
@@ -101,14 +144,25 @@ fun CalendarHost(
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
mutableStateOf(listOf(defaultView))
}
var navigationVersion by remember { mutableIntStateOf(0) }
val view = viewStack.last()
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
// The date every view opens on and carries along (#184); tapping a day
// anywhere focuses it and opens the day view there.
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) }
// Tapping a day in the month grid opens the day view anchored to that date.
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
val switchScope = rememberCoroutineScope()
val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move ->
navigationVersion++
viewStack = move(viewStack)
}
val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } }
val onOpenDay: (LocalDate) -> Unit = { date ->
pendingDayIso = date.toString()
viewStack = viewStack.drillToDay()
focus.navigateTo(date)
switchTo { it.drillToDay() }
}
val onOpenWeek: (LocalDate) -> Unit = { date ->
focus.navigateTo(date)
switchTo { it.drillToWeek() }
}
// The event-detail screen (S4) is a full-screen destination hoisted here so
@@ -196,10 +250,21 @@ fun CalendarHost(
// is "restore a backup", not "add this one event". An externally opened .ics
// keeps routing a single event straight into the prefilled create form.
var importForceMany by remember { mutableStateOf(false) }
// Where a restore came from, so closing the import puts it back: the import
// overlays are declared under Backup & restore and the manager, so starting
// one has to close them — without this, finishing a restore drops you on the
// calendar and the next file means walking in through Settings again.
var backupAfterImport by rememberSaveable { mutableStateOf(false) }
var calendarsAfterImport by rememberSaveable { mutableStateOf(false) }
// One import run. The import VM lives in the Activity's store, so this is
// what tells it a *re-import of the same file* is new work and not the run
// it already finished; a rotation keeps the number and keeps the result.
var importSession by rememberSaveable { mutableStateOf(0) }
LaunchedEffect(requestedImportUri) {
if (requestedImportUri != null) {
importUri = requestedImportUri
importForceMany = false
importSession++
onImportConsumed()
}
}
@@ -247,13 +312,14 @@ fun CalendarHost(
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
// view so backing out returns there (then home), not to the default.
LaunchedEffect(widgetNavRequest) {
if (widgetNavRequest != null) navigationVersion++
when (val req = widgetNavRequest) {
is WidgetNavRequest.OpenDate -> {
// Drill the day view in over the widget's view: drop any overlay
// that would cover it, so the open doesn't land under Settings/form.
dismissCoveringOverlays()
createDateIso = null
pendingDayIso = req.dateIso
focus.navigateTo(LocalDate.parse(req.dateIso))
// No widget source (an external date tap) roots over the default
// home view, so backing out of the day returns home then exits.
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
@@ -277,7 +343,7 @@ fun CalendarHost(
// default home — so backing out returns to the default, then exits.
dismissCoveringOverlays()
createDateIso = null
pendingDayIso = null
focus.navigateTo(localToday())
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
onWidgetNavConsumed()
}
@@ -328,6 +394,9 @@ fun CalendarHost(
}
val slideSpec = rememberCalendarSlideSpec()
// Overlays leave from the predictive-back preview (#184): shrinking on from
// where the gesture let go instead of snapping back and sliding off.
val overlayExit = predictiveBackExit()
// Base-level back: pop the view stack while no overlay covers it (each overlay
// owns its own BackHandler and takes precedence). Disabled at the home view,
@@ -335,71 +404,150 @@ fun CalendarHost(
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
editKey != null || showSettings || showCalendars || showBackup ||
importUri != null || importForm != null
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
viewStack = viewStack.dropLast(1)
// The view switch runs on a seekable transition, so a back gesture can scrub
// it (#185): the previous view morphs in under the finger, releasing finishes
// the pop and cancelling springs back. Reduced motion keeps a plain back.
val viewTransition = remember { SeekableTransitionState(view) }
LaunchedEffect(view) { viewTransition.animateTo(view) }
val reduceMotion = rememberReduceMotion()
val viewBackEnabled = !anyOverlayVisible && viewStack.size > 1
if (reduceMotion) {
BackHandler(enabled = viewBackEnabled) {
navigationVersion++
viewStack = viewStack.dropLast(1)
}
} else {
PredictiveBackHandler(enabled = viewBackEnabled) { events ->
val previous = viewStack[viewStack.size - 2]
try {
events.collect { viewTransition.seekTo(it.progress, targetState = previous) }
navigationVersion++
viewStack = viewStack.dropLast(1)
} catch (e: CancellationException) {
withContext(NonCancellable) { viewTransition.animateTo(viewTransition.currentState) }
throw e
}
}
}
Box(modifier = modifier.fillMaxSize()) {
// Switching between the peer views (month/week/day/agenda) is lateral
// Switching between the month, timeline and agenda screens is lateral
// navigation, so it fades through rather than sliding — paging *within* a
// view keeps the directional slide. AnimatedContent keyed on the view type.
// view keeps the directional slide. What both views show morphs across
// (#184): see [ViewMorphKey]. Reduced motion keeps the plain fade.
val viewSwitch = fadeThrough()
CompositionLocalProvider(LocalEventMove provides moveScope) {
AnimatedContent(
targetState = view,
val morphBounds = rememberViewMorphBoundsTransform()
val morphFade = rememberCalendarFadeSpec()
val morphClip = remember { BelowTopBarClip() }
val stayingPut = remember { StayingPut() }
val chromeHost = remember { ChromeHost() }
val drawerState = rememberDrawerState(DrawerValue.Closed)
val density = LocalDensity.current
val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) +
with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
val timelineScroll = remember { TimelineScrollMemory() }
CompositionLocalProvider(
LocalEventMove provides moveScope,
LocalTimelineScroll provides timelineScroll,
LocalViewFocus provides focus,
// The failure state's way out when every calendar is switched off
// (#239); nothing else in the calendar surfaces navigates here.
LocalManageCalendars provides remember { { showCalendars = true } },
) {
// One drawer, top bar and FAB for every screen (#184): a switch changes
// what they say, from what the screen on show publishes, rather than
// swapping them for another screen's.
ModalNavigationDrawer(
drawerState = drawerState,
// Open only via the menu button where edge-swipe would fight paging;
// the agenda has no paging to fight.
gesturesEnabled = drawerState.isOpen || view.screen == ViewScreen.Agenda,
drawerContent = {
CalendarDrawer(
currentView = view,
currentDate = chromeHost.chrome?.drawerDate ?: focus.date,
drawerState = drawerState,
viewOrder = drawerViewOrder,
onSelectView = { target ->
// Once the drawer is out of the way, so the switch is seen whole.
val requestVersion = ++navigationVersion
switchScope.launch {
drawerState.close()
if (navigationVersion == requestVersion) onSelectView(target)
}
},
onJumpToDate = { target ->
chromeHost.actions?.onJumpToDate?.invoke(target)
switchScope.launch { drawerState.close() }
},
onSettings = {
onOpenSettings()
switchScope.launch { drawerState.close() }
},
)
},
) {
Scaffold(
topBar = {
CalendarTopBar(
host = chromeHost,
selectedView = view,
quickSwitchViews = quickSwitchViews,
onCycleView = { onSelectView(viewStack.last().next(quickSwitchViews)) },
onOpenDrawer = { switchScope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
)
},
floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) },
) { innerPadding ->
SharedTransitionLayout(
Modifier
.padding(innerPadding)
.fillMaxSize()
.onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y },
) {
// Keyed on the screen, not the view: the day, multi-day and week views
// are one timeline that resizes between them itself (#184), so switching
// among them must not replay this transition.
rememberTransition(viewTransition, label = "view-switch").AnimatedContent(
transitionSpec = { viewSwitch },
label = "view-switch",
contentKey = { it.screen },
) { currentView ->
when (currentView) {
CalendarView.Week -> WeekScreen(
selectedView = currentView,
onSelectView = onSelectView,
val screen = currentView.screen
val sharedScope = this@SharedTransitionLayout
val visibilityScope = this@AnimatedContent
val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut) {
if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut)
}
CompositionLocalProvider(
LocalViewMorph provides viewMorph,
LocalChromeSlot provides ChromeSlot(chromeHost, current = screen == view.screen),
) {
when (screen) {
ViewScreen.Timeline -> TimelineScreen(
view = currentView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
)
CalendarView.Day -> DayScreen(
selectedView = currentView,
onSelectView = onSelectView,
ViewScreen.Month -> MonthScreen(
onOpenDay = onOpenDay,
onOpenWeek = onOpenWeek,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
)
CalendarView.Month -> MonthScreen(
selectedView = currentView,
onSelectView = onSelectView,
ViewScreen.Agenda -> AgendaScreen(
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
)
CalendarView.Agenda -> AgendaScreen(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
)
}
}
}
}
}
}
}
@@ -412,11 +560,12 @@ fun CalendarHost(
AnimatedVisibility(
visible = showSearch,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
SearchScreen(
onBack = { showSearch = false },
onEventClick = onEventClick,
visible = showSearch,
)
}
@@ -425,7 +574,7 @@ fun CalendarHost(
AnimatedVisibility(
visible = detailKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
activeKey?.let { key ->
EventDetailScreen(
@@ -445,6 +594,7 @@ fun CalendarHost(
importForm = form
detailKey = null
},
visible = detailKey != null,
)
}
}
@@ -453,7 +603,7 @@ fun CalendarHost(
AnimatedVisibility(
visible = createDateIso != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
(createDateIso ?: heldCreateIso)?.let { iso ->
EventEditScreen(
@@ -462,6 +612,7 @@ fun CalendarHost(
onClose = { createDateIso = null },
onSaved = { createDateIso = null },
onManageCalendars = { showCalendars = true },
visible = createDateIso != null,
)
}
}
@@ -470,7 +621,7 @@ fun CalendarHost(
AnimatedVisibility(
visible = editKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
(editKey ?: heldEditKey)?.let { key ->
EventEditScreen(
@@ -482,6 +633,7 @@ fun CalendarHost(
detailKey = null
},
onManageCalendars = { showCalendars = true },
visible = editKey != null,
)
}
}
@@ -490,12 +642,13 @@ fun CalendarHost(
AnimatedVisibility(
visible = showSettings,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
SettingsScreen(
onBack = { showSettings = false },
onManageCalendars = { showCalendars = true },
onOpenBackup = { showBackup = true },
visible = showSettings,
)
}
@@ -504,11 +657,28 @@ fun CalendarHost(
importUri?.let { uri ->
ImportScreen(
uri = uri,
session = importSession,
forceMany = importForceMany,
onClose = { importUri = null },
onManageCalendars = { showCalendars = true },
onClose = {
importUri = null
// Back to the surface the restore started from, ready for
// the next file.
showCalendars = calendarsAfterImport
showBackup = backupAfterImport
calendarsAfterImport = false
backupAfterImport = false
},
onManageCalendars = {
// The manager is where this leads, so it must not be
// reopened underneath on close.
calendarsAfterImport = false
backupAfterImport = false
showCalendars = true
},
onOpenSingle = { form ->
importUri = null
calendarsAfterImport = false
backupAfterImport = false
importFormSource = ImportSource.File
importForm = form
},
@@ -530,11 +700,12 @@ fun CalendarHost(
AnimatedVisibility(
visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
CalendarsScreen(
onBack = { showCalendars = false },
onOpenBackup = { showBackup = true },
visible = showCalendars,
)
}
@@ -542,20 +713,36 @@ fun CalendarHost(
AnimatedVisibility(
visible = showBackup,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
BackupScreen(
onBack = { showBackup = false },
// Restore runs the normal .ics import, and both this screen and
// the manager that can have opened it are declared above the
// import overlays — so both have to step aside.
onImport = {
importUri = it
importForceMany = true
// Settings can open this screen without the manager underneath, so
// its failure states' "Manage calendars" way out has to open the
// manager rather than just pop — popping alone lands on Settings
// (#304). Coming from the manager the flag is already set, so this
// is the same pop-back there.
CompositionLocalProvider(
LocalManageCalendars provides {
showCalendars = true
showBackup = false
showCalendars = false
},
)
) {
BackupScreen(
onBack = { showBackup = false },
// Restore runs the normal .ics import, and both this screen
// and the manager that can have opened it are declared above
// the import overlays — so both have to step aside.
onImport = {
importUri = it
importForceMany = true
importSession++
backupAfterImport = true
calendarsAfterImport = showCalendars
showBackup = false
showCalendars = false
},
visible = showBackup,
)
}
}
}
}
@@ -565,3 +752,6 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
save = { stack -> stack.map(CalendarView::ordinal) },
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
)
private fun localToday(): LocalDate =
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
@@ -29,6 +29,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
@@ -1,5 +1,18 @@
package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
import de.jeanlucmakiola.calendula.ui.common.ScreenChrome
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus
import kotlinx.coroutines.flow.drop
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -13,34 +26,25 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -51,16 +55,13 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.floret.locale.currentLocale
@@ -76,27 +77,22 @@ import kotlin.time.Instant
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AgendaScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier,
viewModel: AgendaViewModel = hiltViewModel(),
) {
// Opens on the focused date and hands back the day scrolled to the top.
val focus = LocalViewFocus.current
EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) }
val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
var showRangePicker by remember { mutableStateOf(false) }
@@ -106,92 +102,102 @@ fun AgendaScreen(
}
val successState = state as? AgendaUiState.Success
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
CalendarDrawer(
currentView = selectedView,
currentDate = anchor,
drawerState = drawerState,
viewOrder = drawerViewOrder,
onSelectView = { view ->
onSelectView(view)
scope.launch { drawerState.close() }
},
onJumpToDate = { target ->
viewModel.goToDate(target)
scope.launch { drawerState.close() }
},
onSettings = {
onOpenSettings()
scope.launch { drawerState.close() }
},
)
},
) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
AgendaTopBar(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = viewModel::goToToday,
scrollBehavior = scrollBehavior,
)
// The agenda window always starts at the anchor, so moving through it is a
// list scroll, not an anchor change — "today" has to bring the list back to
// the top as well, and stay offered while it is scrolled away (#305).
val listState = rememberLazyListState()
val scrolledAway by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
}
}
// A new window is rendered from its first day, so the list belongs at the
// top. Keyed on the anchor the days arrived with, not the one just
// requested: scrolling before the new rows compose leaves the list keyed to
// a row that reappears further down, and it follows it there.
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
val jumpToToday: () -> Unit = {
// Off today, moving the anchor is what resets the list (above); on today
// the window doesn't change, so the scroll back is the whole action.
if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
viewModel.goToToday()
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
}
PublishChrome(
ScreenChrome(
title = stringResource(R.string.view_agenda),
titleDate = null,
drawerDate = anchor,
awayFromToday = !isOnToday || (scrolledAway && successState != null),
todayText = stringResource(R.string.agenda_today_action),
),
ScreenActions(
onToday = jumpToToday,
onJumpToDate = { target ->
focus?.date = target
viewModel.goToDate(target)
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = !isOnToday && !todayInToolbar,
todayText = stringResource(R.string.agenda_today_action),
onToday = viewModel::goToToday,
onCreate = { onCreateEvent(anchor, null) },
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
// One bar at the top: the "showing …" header on the left and the
// session range switcher on the right (one settings toggle).
successState?.takeIf { it.showRangeBar }?.let { s ->
Row(
verticalAlignment = Alignment.CenterVertically,
// end aligns the selector's right edge with the top-bar view
// switcher (its 8.dp margin + the app bar's 4.dp inset).
modifier = Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
) {
AgendaRangeBanner(
range = s.range,
start = s.anchor,
end = s.rangeEnd,
modifier = Modifier.weight(1f),
)
AgendaRangePill(
range = s.range,
isOverride = s.rangeIsOverride,
onClick = { showRangePicker = true },
)
}
onCreate = { onCreateEvent(anchor, null) },
),
)
ScreenBody(modifier = modifier) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
// One bar at the top: the "showing …" header on the left and the
// session range switcher on the right (one settings toggle).
successState?.takeIf { it.showRangeBar }?.let { s ->
// end lines the selector up with whatever ends the top bar:
// the view switcher's background, or — once #150 hides it —
// the search icon's glyph.
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
AppBarSpacing.Inset
} else {
AppBarSpacing.IconTrailingInset
}
AgendaContent(
state = state,
pastDisplay = pastDisplay,
showToday = showToday,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
)
.fillMaxWidth()
.padding(
start = RANGE_BAR_TEXT_INSET,
end = selectorEnd,
top = 8.dp,
bottom = 8.dp,
),
) {
AgendaRangeBanner(
range = s.range,
start = s.anchor,
end = s.rangeEnd,
modifier = Modifier.weight(1f),
)
AgendaRangePill(
range = s.range,
isOverride = s.rangeIsOverride,
onClick = { showRangePicker = true },
)
}
}
AgendaContent(
state = state,
pastDisplay = pastDisplay,
showToday = showToday,
listState = listState,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onTopDay = if (successState?.anchor == anchor) {
{ focus?.date = it }
} else null,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
)
}
}
@@ -245,6 +251,9 @@ private fun AgendaRangePill(
}
}
/** Optical start inset for the range bar's text, set against the title above it. */
private val RANGE_BAR_TEXT_INSET = 28.dp
/**
* A header naming the concrete window currently shown under a "showing …" label,
* e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name
@@ -284,6 +293,8 @@ internal fun AgendaContent(
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
onTopDay: ((LocalDate) -> Unit)? = null,
) {
when (state) {
AgendaUiState.Loading -> Box(modifier)
@@ -312,16 +323,21 @@ internal fun AgendaContent(
enabled = showToday && state.anchor == state.today,
)
if (days.isEmpty()) {
// Nothing to scroll: drop the position a previous list left, so
// the today FAB doesn't linger over an empty screen.
LaunchedEffect(Unit) { listState.scrollToItem(0) }
AgendaEmpty(modifier)
} else {
AgendaList(
days = days,
today = state.today,
zone = state.zone,
listState = listState,
dimPast = pastDisplay == PastEventDisplay.DIM,
now = now,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onTopDay = onTopDay,
modifier = modifier,
)
}
@@ -335,14 +351,28 @@ private fun AgendaList(
days: List<AgendaDay>,
today: LocalDate,
zone: TimeZone,
listState: LazyListState,
dimPast: Boolean,
now: Instant,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onTopDay: ((LocalDate) -> Unit)?,
modifier: Modifier = Modifier,
) {
if (onTopDay != null) {
val itemDates by rememberUpdatedState(agendaItemDates(days))
val currentOnTopDay by rememberUpdatedState(onTopDay)
// The day the list opened on is where the view already is; only a
// scroll moves it on.
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.drop(1)
.collect { index -> itemDates.getOrNull(index)?.let(currentOnTopDay) }
}
}
LazyColumn(
modifier = modifier,
state = listState,
// Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
) {
@@ -372,7 +402,7 @@ private fun AgendaList(
zone = zone,
position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now),
modifier = animateItemMotion(),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
onClick = { onEventClick(event) },
)
}
@@ -382,6 +412,13 @@ private fun AgendaList(
}
}
/** The day of each item [AgendaList] lays out for [days], in its order. */
internal fun agendaItemDates(days: List<AgendaDay>): List<LocalDate> =
days.flatMap { day ->
// Header, the placeholder or the events, then the gap.
List(2 + day.events.size.coerceAtLeast(1)) { day.date }
}
@Composable
private fun AgendaEmpty(modifier: Modifier = Modifier) {
Column(
@@ -403,51 +440,3 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AgendaTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
) {
TopAppBar(
title = {
Text(
text = stringResource(R.string.view_agenda),
style = MaterialTheme.typography.titleLarge,
)
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
),
scrollBehavior = scrollBehavior,
)
}
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.domain.spanLastDay
@@ -86,11 +87,7 @@ fun groupAgendaDays(
return byDay.map { (date, dayEvents) ->
AgendaDay(
date = date,
events = dayEvents.sortedWith(
compareByDescending<EventInstance> { it.isAllDay }
.thenBy { it.start }
.thenBy { it.title },
),
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
)
}
}
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -156,9 +157,7 @@ class AgendaViewModel @Inject constructor(
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): AgendaUiState {
if (calendars.isEmpty()) {
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
}
calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) }
val anchor = params.anchor
val rangeEnd = anchor.plus(
params.range.dayCount(anchor, params.weekStart) - 1,
@@ -4,9 +4,11 @@ import android.net.Uri
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -18,6 +20,7 @@ import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@@ -47,9 +50,10 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.isEventTarget
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
@@ -78,20 +82,16 @@ private val RESTORE_MIME_TYPES = arrayOf(
fun BackupScreen(
onBack: () -> Unit,
onImport: (Uri) -> Unit,
visible: Boolean = true,
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val state by viewModel.backupState.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
// Export covers local calendars only; managed special-dates mirrors are
// rebuilt from contacts. Restore can target anything the import picker offers.
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
val canImport = calendars.any { it.isEventTarget }
// Exports everything eligible (null); the per-calendar selector owns its
// own launcher.
val createBackup = rememberLauncherForActivityResult(
@@ -132,78 +132,43 @@ fun BackupScreen(
CollapsingScaffold(
title = stringResource(R.string.settings_section_backup),
onBack = onBack,
// Loading and failure fill the screen and centre themselves, which they
// can only do in an unscrolled column — the scrolling one measures them
// against an unbounded height and leaves them hanging under the header.
scrollable = state is BackupUiState.Ready,
snackbarHost = { SnackbarHost(snackbarHostState) },
predictiveBack = true,
visible = visible,
) {
HintText(stringResource(R.string.calendars_backup_hint))
if (exportable.isNotEmpty()) {
GroupedRow(
title = stringResource(R.string.calendars_backup_action),
position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
onClick = {
// A single exportable calendar skips the selector.
if (exportable.size == 1) {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
} else {
showExportPicker = true
}
when (val s = state) {
BackupUiState.Loading -> BackupLoading()
is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack)
is BackupUiState.Ready -> BackupContent(
exportable = s.exportable,
canImport = s.canImport,
autoBackup = autoBackup,
viewModel = viewModel,
onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
onPickFolder = { runCatching { pickFolder.launch(null) } },
onExportAll = {
runCatching { createBackup.launch(defaultBackupName()) }
},
)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
summary = stringResource(R.string.calendars_restore_hint),
position = Position.Middle,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup),
summary = stringResource(R.string.calendars_auto_backup_hint),
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
leading = { LeadingAvatar(Icons.Default.Schedule) },
trailing = {
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
},
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
)
if (autoBackup.enabled) {
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_folder),
summary = rememberFolderName(autoBackup.folderUri)
?: stringResource(R.string.calendars_auto_backup_folder_unset),
position = Position.Middle,
onClick = { runCatching { pickFolder.launch(null) } },
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_interval),
summary = backupIntervalLabel(autoBackup.intervalMinutes),
position = Position.Bottom,
onClick = { showInterval = true },
)
HintText(backupStatusText(autoBackup.status))
}
} else if (canImport) {
// Nothing to back up, but restore is still possible — don't hide
// it behind export eligibility.
SectionHeader(stringResource(R.string.calendars_restore_header))
HintText(stringResource(R.string.calendars_restore_hint))
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
onExportPick = { showExportPicker = true },
onEditInterval = { showInterval = true },
)
}
}
if (showExportPicker) {
ExportCalendarPicker(
calendars = exportable,
onExport = viewModel::exportBackup,
onDismiss = { showExportPicker = false },
)
// Gated on Ready rather than resetting the flag when the state leaves it:
// flipping it here would be a write during composition.
(state as? BackupUiState.Ready)?.let { ready ->
if (showExportPicker) {
ExportCalendarPicker(
calendars = ready.exportable,
onExport = viewModel::exportBackup,
onDismiss = { showExportPicker = false },
)
}
}
if (showInterval) {
BackupIntervalDialog(
@@ -214,6 +179,103 @@ fun BackupScreen(
}
}
/** The screen's loading state, centred in the scaffold's content column. */
@Composable
private fun BackupLoading() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
/** Default file name for a one-shot export. */
private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.ics"
/**
* The working screen: export (when there are local calendars to export), restore,
* and the automatic-backup block. Restore is always on screen — when nothing can
* receive the events it says so and routes to the calendar manager rather than
* disappearing, which is what #304 reported as an invisible button.
*/
@Composable
private fun BackupContent(
exportable: List<CalendarSource>,
canImport: Boolean,
autoBackup: AutoBackupUiState,
viewModel: CalendarsViewModel,
onRestore: () -> Unit,
onPickFolder: () -> Unit,
onExportAll: () -> Unit,
onExportPick: () -> Unit,
onEditInterval: () -> Unit,
) {
val manageCalendars = LocalManageCalendars.current
// Restore never depends on export eligibility, and never disappears: when no
// calendar can receive events it explains that and offers the way to fix it.
val restoreSummary = if (canImport) {
stringResource(R.string.calendars_restore_hint)
} else {
stringResource(R.string.calendars_restore_unavailable)
}
val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore)
if (exportable.isEmpty()) {
SectionHeader(stringResource(R.string.calendars_restore_header))
HintText(restoreSummary)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = onRestoreClick,
)
return
}
HintText(stringResource(R.string.calendars_backup_hint))
GroupedRow(
title = stringResource(R.string.calendars_backup_action),
position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
// A single exportable calendar skips the selector.
onClick = if (exportable.size == 1) onExportAll else onExportPick,
)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
summary = restoreSummary,
position = Position.Middle,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = onRestoreClick,
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup),
summary = stringResource(R.string.calendars_auto_backup_hint),
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
leading = { LeadingAvatar(Icons.Default.Schedule) },
trailing = {
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
},
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
)
if (autoBackup.enabled) {
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_folder),
summary = rememberFolderName(autoBackup.folderUri)
?: stringResource(R.string.calendars_auto_backup_folder_unset),
position = Position.Middle,
onClick = onPickFolder,
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_interval),
summary = backupIntervalLabel(autoBackup.intervalMinutes),
position = Position.Bottom,
onClick = onEditInterval,
)
HintText(backupStatusText(autoBackup.status))
}
}
/**
* Choose which local calendars to include in a one-time `.ics` export. Defaults
* to all selected; the Export action opens the SAF save dialog and hands back
@@ -252,7 +314,7 @@ private fun ExportCalendarPicker(
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id in selected
GroupedRow(
title = calendar.displayName,
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
summary = calendar.description,
position = positionOf(index, calendars.size),
leading = { CalendarColorChip(calendar.color) },
@@ -0,0 +1,53 @@
package de.jeanlucmakiola.calendula.ui.calendars
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.domain.isEventTarget
/**
* State of the Backup & restore screen (#69). Three states, because the calendar
* list it is derived from has all three: it arrives empty, it can throw, and
* "loaded but nothing to offer" is a real outcome the screen used to render as a
* blank page (#304).
*/
sealed interface BackupUiState {
data object Loading : BackupUiState
data class Failure(val reason: FailureReason) : BackupUiState
/**
* At least one half of the screen works. [exportable] may be empty (restore
* only) and [canImport] may be false (export only), but never both — that is
* a [Failure].
*/
data class Ready(
val exportable: List<CalendarSource>,
val canImport: Boolean,
) : BackupUiState
}
/**
* What the screen can offer for this calendar list.
*
* Export covers the app's own local calendars; managed special-dates mirrors are
* rebuilt from contacts, so they are excluded. Restore can target anything the
* import picker offers ([isEventTarget]).
*
* A failure is raised only when *neither* is possible — deliberately not
* [calendarListFailure] on its own, which would call an all-hidden device a
* failure while its local calendars are still perfectly exportable. When both
* halves are dead the list itself usually says why (no calendars, everything
* switched off); [FailureReason.NoImportTarget] covers the remaining case, where
* calendars exist and are visible but every one of them is read-only, managed or
* not synced to the device.
*/
fun backupUiState(calendars: List<CalendarSource>): BackupUiState {
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
val canImport = calendars.any { it.isEventTarget }
if (exportable.isEmpty() && !canImport) {
return BackupUiState.Failure(
calendarListFailure(calendars) ?: FailureReason.NoImportTarget,
)
}
return BackupUiState.Ready(exportable = exportable, canImport = canImport)
}
@@ -90,6 +90,7 @@ import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
@@ -97,11 +98,25 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.heightIn
import androidx.annotation.StringRes
import androidx.compose.foundation.border
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.filled.Check
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.ImeAction
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedSurface
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
@@ -118,6 +133,7 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
fun CalendarsScreen(
onBack: () -> Unit,
onOpenBackup: () -> Unit,
visible: Boolean = true,
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
@@ -162,6 +178,7 @@ fun CalendarsScreen(
onConsumeError = viewModel::consumeError,
onOpenBackup = onOpenBackup,
onBack = onBack,
visible = visible,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id },
onSetVisible = viewModel::setCalendarVisible,
@@ -178,6 +195,7 @@ private fun CalendarsList(
onConsumeError: () -> Unit,
onOpenBackup: () -> Unit,
onBack: () -> Unit,
visible: Boolean,
onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit,
onSetVisible: (Long, Boolean) -> Unit,
@@ -203,6 +221,7 @@ private fun CalendarsList(
onBack = onBack,
snackbarHost = { SnackbarHost(snackbarHostState) },
predictiveBack = true,
visible = visible,
) {
// What the per-calendar / per-account switches below actually do.
HintText(stringResource(R.string.calendars_visibility_hint))
@@ -231,7 +250,7 @@ private fun CalendarsList(
local.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem
GroupedRow(
title = calendar.displayName,
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
summary = calendarRowSummary(calendar),
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
@@ -239,7 +258,7 @@ private fun CalendarsList(
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = {
EnableSwitch(
calendarName = calendar.displayName,
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
enabled = !disabled,
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
)
@@ -316,7 +335,7 @@ private fun CalendarsList(
ordered.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
GroupedRow(
title = calendar.displayName,
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
summary = calendarRowSummary(calendar),
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
@@ -325,7 +344,7 @@ private fun CalendarsList(
trailing = if (calendar.hasVisibilitySwitch) {
{
EnableSwitch(
calendarName = calendar.displayName,
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
enabled = calendar.isVisibleInSystem,
onToggle = { enabled ->
onSetVisible(calendar.id, enabled)
@@ -343,9 +362,15 @@ private fun CalendarsList(
}
@OptIn(ExperimentalMaterial3Api::class)
private const val SWATCHES_PER_ROW = 6
/**
* Create or edit a local calendar: a name field over the palette of calendar
* colours, on the family's full-screen sheet with the commit in its title bar.
* The same sheet Agendula edits a task list in, so the two read as one product.
*/
@Composable
private fun CalendarEditor(
internal fun CalendarEditor(
sessionKey: Int,
isNew: Boolean,
initialName: String,
@@ -360,121 +385,50 @@ private fun CalendarEditor(
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
var confirmDelete by remember { mutableStateOf(false) }
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
val focusRequester = remember { FocusRequester() }
Scaffold(
modifier = Modifier
.predictiveBack(onBack = onClose)
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
topBar = {
TopAppBar(
title = {
Text(
stringResource(
if (isNew) R.string.calendars_new_title
else R.string.calendars_edit_title,
),
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.event_edit_close),
)
}
},
actions = {
if (!isNew) {
// Disabled rather than hidden while the special-dates
// sync owns this calendar; the card below says why.
IconButton(
onClick = { confirmDelete = true },
enabled = !deleteLocked,
) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete),
tint = if (deleteLocked) {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
} else {
MaterialTheme.colorScheme.error
},
)
}
}
// Filled save button, matching the event editor's top bar.
Button(
onClick = {
onSave(name.trim(), color, description.trim().ifEmpty { null })
},
enabled = name.isNotBlank(),
modifier = Modifier.padding(end = 12.dp),
) {
Text(stringResource(R.string.event_edit_save))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
val valid = name.isNotBlank()
val commit = { if (valid) onSave(name.trim(), color, description.trim().ifEmpty { null }) }
FullScreenPicker(
title = stringResource(
if (isNew) R.string.calendars_new_title else R.string.calendars_edit_title,
),
onDismiss = onClose,
predictiveBack = true,
actions = {
Button(
onClick = commit,
enabled = valid,
modifier = Modifier.padding(end = 12.dp),
) { Text(stringResource(R.string.event_edit_save)) }
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (deleteLocked) {
EditorCard(
icon = Icons.Default.Info,
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
iconAtTop = true,
) {
Text(
text = stringResource(R.string.calendars_managed_delete_locked),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
InlineTextField(
value = name,
onValueChange = { name = it },
placeholder = stringResource(R.string.calendars_name_label),
textStyle = MaterialTheme.typography.titleLarge,
capitalization = KeyboardCapitalization.Sentences,
)
}
EditorCard(
icon = Icons.Default.Palette,
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
iconAtTop = true,
) {
Text(
text = stringResource(R.string.calendars_color_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
ColorSwatchRow(
colors = CalendarColorPalette.all,
selected = color,
onSelect = { color = it },
dark = dark,
)
}
EditorCard(
icon = Icons.AutoMirrored.Filled.Notes,
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
iconAtTop = true,
) {
) {
if (deleteLocked) {
ManagedNote()
Spacer(Modifier.height(20.dp))
}
CalendarNameField(
name = name,
color = color,
focusRequester = focusRequester,
onNameChange = { name = it },
onImeAction = commit,
)
// Inside the picker: it is a Dialog, so a request made from the caller's
// composition can run before this field's node exists.
// A new calendar opens with the keyboard up: naming it is the whole task.
LaunchedEffect(isNew) { if (isNew) focusRequester.requestFocus() }
Spacer(Modifier.height(20.dp))
EditorSectionLabel(stringResource(R.string.calendars_color_label))
CalendarColorGrid(selected = color, onSelect = { color = it })
Spacer(Modifier.height(20.dp))
EditorSectionLabel(stringResource(R.string.calendars_description_label))
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
Box(Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(16.dp)) {
InlineTextField(
value = description,
onValueChange = { description = it },
@@ -485,20 +439,21 @@ private fun CalendarEditor(
)
}
}
if (!isNew && !deleteLocked) {
Spacer(Modifier.height(24.dp))
DeleteCalendarRow(onClick = { confirmDelete = true })
}
Spacer(Modifier.height(24.dp))
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text(stringResource(R.string.calendars_delete_confirm_title)) },
text = {
Text(stringResource(R.string.calendars_delete_confirm_message, initialName))
},
text = { Text(stringResource(R.string.calendars_delete_confirm_message, initialName)) },
confirmButton = {
TextButton(onClick = {
confirmDelete = false
onDelete()
}) {
TextButton(onClick = { confirmDelete = false; onDelete() }) {
Text(
stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
@@ -514,6 +469,205 @@ private fun CalendarEditor(
}
}
/** The name, with the chosen colour beside it so the two read as one thing. */
@Composable
private fun CalendarNameField(
name: String,
color: Int,
focusRequester: FocusRequester,
onNameChange: (String) -> Unit,
onImeAction: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.CalendarMonth,
contentDescription = null,
tint = eventAccent(color, dark, soften),
modifier = Modifier.size(22.dp),
)
}
InlineTextField(
value = name,
onValueChange = onNameChange,
placeholder = stringResource(R.string.calendars_name_label),
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Done,
onImeAction = onImeAction,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
)
}
}
}
/**
* The palette as two rows of round swatches; the chosen one carries a check.
*
* Drawn through the app's own tone pass rather than raw, so a swatch is the
* colour the calendar's events will actually appear in — the user's "soften
* colours" preference included.
*/
@Composable
private fun CalendarColorGrid(selected: Int, onSelect: (Int) -> Unit) {
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
Column(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 16.dp)
.selectableGroup(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
CalendarColorPalette.all.chunked(SWATCHES_PER_ROW).forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
row.forEach { swatch ->
ColorSwatch(
fill = eventFill(swatch, dark, soften),
label = stringResource(swatchLabel(swatch)),
selected = swatch == selected,
onClick = { onSelect(swatch) },
modifier = Modifier.weight(1f),
)
}
// Keeps a short final row's swatches the size a full row's
// are, rather than stretching them across the width.
repeat(SWATCHES_PER_ROW - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
}
@Composable
private fun ColorSwatch(
fill: Color,
label: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.aspectRatio(1f)
.clip(CircleShape)
.background(fill)
.then(
if (selected) {
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
} else {
Modifier
},
)
// selectable, not clickable: the swatch carries its chosen state in
// semantics too, so the check is not the only cue.
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
.semantics { contentDescription = label },
contentAlignment = Alignment.Center,
) {
if (selected) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = eventInk(fill, alpha = 0.7f),
modifier = Modifier.size(22.dp),
)
}
}
}
/** Names the swatch for a screen reader — twelve circles are otherwise one label. */
@StringRes
private fun swatchLabel(argb: Int): Int = when (argb) {
CalendarColorPalette.Mauve -> R.string.color_name_mauve
CalendarColorPalette.Red -> R.string.color_name_red
CalendarColorPalette.Orange -> R.string.color_name_orange
CalendarColorPalette.Amber -> R.string.color_name_amber
CalendarColorPalette.Olive -> R.string.color_name_olive
CalendarColorPalette.Green -> R.string.color_name_green
CalendarColorPalette.Teal -> R.string.color_name_teal
CalendarColorPalette.Cyan -> R.string.color_name_cyan
CalendarColorPalette.Blue -> R.string.color_name_blue
CalendarColorPalette.Indigo -> R.string.color_name_indigo
CalendarColorPalette.Purple -> R.string.color_name_purple
else -> R.string.color_name_pink
}
/** Said in the sheet rather than on a disabled button: the sync owns this one. */
@Composable
private fun ManagedNote() {
GroupedSurface(
position = Position.Alone,
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(Icons.Default.Info, contentDescription = null)
Text(
text = stringResource(R.string.calendars_managed_delete_locked),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun DeleteCalendarRow(onClick: () -> Unit) {
GroupedSurface(
position = Position.Alone,
modifier = Modifier.padding(horizontal = 16.dp),
onClick = onClick,
color = MaterialTheme.colorScheme.errorContainer,
) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
text = stringResource(R.string.event_detail_delete),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
}
@Composable
private fun EditorSectionLabel(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, bottom = 8.dp),
)
}
/**
* The row's supporting line: the states that make this calendar behave unlike a
* plain writable one (#76), then its own description.
@@ -556,36 +710,6 @@ private fun EnableSwitch(
private fun dimIf(disabled: Boolean): Modifier =
if (disabled) Modifier.alpha(0.38f) else Modifier
/** Tonal field card matching the event editor's design (icon + content). */
@Composable
private fun EditorCard(
icon: ImageVector,
iconTint: Color,
iconAtTop: Boolean = false,
content: @Composable () -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = iconTint,
modifier = Modifier
.padding(top = if (iconAtTop) 2.dp else 0.dp)
.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) { content() }
}
}
}
/**
* One collapsible calendar group rendered as a connected card. The header row is
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
@@ -24,6 +25,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -56,6 +58,22 @@ class CalendarsViewModel @Inject constructor(
initialValue = emptyList(),
)
/**
* The Backup & restore screen's own view of that list, with the loading and
* failure states [calendars] flattens away — it starts empty and catches to
* empty, which that screen used to render as a blank page (#304).
*/
val backupState: StateFlow<BackupUiState> =
repository.calendars()
.map { backupUiState(it) }
.catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = BackupUiState.Loading,
)
/** Automatic-backup settings + last-run status, for the Backup section UI. */
val autoBackup: StateFlow<AutoBackupUiState> = combine(
settingsPrefs.autoBackupEnabled,
@@ -56,12 +56,14 @@ fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
* account comes from when another account shares the name (#77).
*/
@Composable
fun accountGroupTitle(group: CalendarAccountGroup): String =
if (!group.ambiguous) {
group.label
fun accountGroupTitle(group: CalendarAccountGroup): String {
val label = group.label.ifBlank { stringResource(R.string.calendar_unnamed) }
return if (!group.ambiguous) {
label
} else {
stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType))
stringResource(R.string.calendars_account_from_source, label, sourceAppName(group.accountType))
}
}
/**
* The human name of the app backing [accountType], falling back to the raw
@@ -0,0 +1,41 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.unit.dp
/**
* Trailing-edge and title spacing shared by the four calendar app bars (#165).
*
* Only the trailing edge and the title's start are shared; the leading edge and
* the gaps between the action icons stay on M3's defaults, which derive both
* from the icon buttons' width.
*/
object AppBarSpacing {
/** Side inset shared by the bars' trailing edge and the month grid. */
val Inset = 8.dp
/**
* Start inset on the title, taking it clear of the navigation icon M3 places
* it flush against. Real padding rather than a negative offset, which would
* swallow taps meant for the menu button.
*/
val TitleInset = 8.dp
/** M3's own padding around the actions row. */
internal val BarPadding = 4.dp
private val IconButtonSize = 48.dp
/** M3's icon slot inside an icon button; the today glyph fills the same box. */
internal val IconSize = 24.dp
/**
* End padding for a container-backed control that ends the bar, measured to
* its background. Coerced because [androidx.compose.foundation.layout.padding]
* throws on a negative value.
*/
val ContainerTrailingInset = (Inset - BarPadding).coerceAtLeast(0.dp)
/** Screen edge to the glyph of an icon button that ends the bar. */
val IconTrailingInset = BarPadding + (IconButtonSize - IconSize) / 2
}
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
@@ -10,24 +11,207 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlin.math.roundToInt
/** Gap a timed block leaves to its neighbours in the column. */
val BLOCK_OUTER_INSET = 1.dp
/** Padding between a timed block's edge and its text. */
val BLOCK_TEXT_PADDING = 4.dp
/** The same, above and below — what a block's height has to pay before any text. */
val BLOCK_TEXT_INSET = 2.dp
/**
* What a timed block of a given height has to spend on text, and what each line
* of it costs.
*/
@Immutable
data class BlockTextMetrics(
/** Padding above and below the text — see [rememberBlockTextMetrics]. */
val inset: Dp,
/** Height left for text once [inset] is paid at both edges. */
val available: Dp,
/** What the first line of a title draws in. */
val titleLine: Dp,
/** What every title line after the first adds. */
val titleLeading: Dp,
/** What the time label's one line draws in. */
val timeLine: Dp,
) {
/** Whether the block can draw a title at all. */
val fitsTitle: Boolean get() = available >= titleLine
/** Height a title of [lines] lines occupies. */
fun titleHeight(lines: Int): Dp =
if (lines <= 0) 0.dp else titleLine + titleLeading * (lines - 1)
/** Title lines that fit [within], which may be none. */
fun titleBudget(within: Dp): Int =
if (within < titleLine) 0 else 1 + ((within - titleLine) / titleLeading).toInt()
}
/**
* The vertical padding a block [height] tall can afford around a title line of
* [titleLine].
*
* The inset is what the block gives up first: breathing room is worth having
* where there is room to breathe, but on a block down to its last few pixels a
* bare colour chip where a label would have fit reads as a rendering fault. It
* tapers rather than snapping, so a pinch closes the gap gradually instead of
* dropping it in one frame (#289).
*/
internal fun blockTextInset(height: Dp, titleLine: Dp): Dp =
minOf(BLOCK_TEXT_INSET, (height - titleLine) / 2).coerceAtLeast(0.dp)
/** Text metrics for a timed block [height] tall. */
@Composable
fun rememberBlockTextMetrics(height: Dp): BlockTextMetrics {
val titleStyle = MaterialTheme.typography.labelMedium
val titleLine = rememberTrimmedLineHeight(titleStyle)
// Packed, so a second line costs what the first did rather than a whole
// Material line box — the gap between two lines of a wrapped title is the
// one place a block pays that leading twice (#190).
val titleLeading = titleLine
val timeLine = rememberTrimmedLineHeight(MaterialTheme.typography.labelSmall.asEventTime())
val inset = blockTextInset(height, titleLine)
return BlockTextMetrics(
inset = inset,
available = height - inset * 2,
titleLine = titleLine,
titleLeading = titleLeading,
timeLine = timeLine,
)
}
/** Most lines a time label may wrap over before it is worth more than a title line. */
const val MAX_TIME_LINES = 2
/**
* Lines [text] needs to render whole at [textWidth], capped at [max].
*
* Lets a block hand out its height by what the text actually asks for rather
* than by what would fit: a title that wants one line should not be given three
* that the time label could have used, and a week column is narrower than a
* "09:30–11:00" range so the range should not be assumed to want one.
*/
@Composable
fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int {
val measurer = rememberTextMeasurer()
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
return remember(text, style, widthPx, max, measurer) {
if (max <= 1 || widthPx <= 0) {
1
} else {
measurer.measure(
text = text,
style = style,
constraints = Constraints(maxWidth = widthPx),
).lineCount.coerceIn(1, max)
}
}
}
/**
* Lines the time label may take at [textWidth], out of the [spare] height left
* once the title and the label's own first line are paid for.
*
* A week column is narrower than a "09:30–11:00" range, so the label takes a
* second line rather than lose its end — but only out of a line the title
* measured itself as not needing, never one it would have filled.
*/
@Composable
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
val timeLineHeight = rememberTrimmedLineHeight(
MaterialTheme.typography.labelSmall.asEventTime(),
)
return if (spare >= timeLineHeight) {
blockTextLines(
text = label,
// The style it is drawn in, or the budget measures a line the label
// never uses (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
textWidth = textWidth,
max = MAX_TIME_LINES,
)
} else {
1
}
}
/**
* A timed block's title, over at most [maxLines], breaking at word boundaries.
*
* #164 clipped the last line mid-glyph so none of a narrow chip's few
* characters went on an ellipsis. On a block that can wrap, whole words read
* better than full lines do: "Farmers Market" over two lines beats "Farmer" /
* "s Marke". A single line still clips, having nowhere to wrap to.
*/
@Composable
fun BlockTitle(
title: String,
maxLines: Int,
color: Color,
modifier: Modifier = Modifier,
textDecoration: TextDecoration? = null,
fontWeight: FontWeight? = null,
) {
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
Text(
text = title,
modifier = modifier,
style = rememberPackedLines(
MaterialTheme.typography.labelMedium
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
),
maxLines = maxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
color = color,
textDecoration = textDecoration,
)
}
/**
* A timed block's own time label, crossfaded rather than replaced — the block
* slides to its new slot, so the label shouldn't change in a single frame.
*
* Overflows like a title does (#164): the "…" costs two characters of a string
* that is nothing but characters, so the label clips at the block's edge
* instead. [maxLines] lets a narrow column spend spare height on the range
* rather than losing its end.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
fun BlockTimeLabel(
label: String,
color: Color,
modifier: Modifier = Modifier,
maxLines: Int = 1,
) {
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
snap()
} else {
MaterialTheme.motionScheme.fastEffectsSpec()
}
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
// Regular weight against the title's medium above it (#219).
val style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime())
Crossfade(
targetState = label,
animationSpec = spec,
@@ -36,17 +220,22 @@ fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
) { text ->
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = style,
maxLines = maxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
color = color,
)
}
}
/** Where a timed block sits in its column, after tweening. */
/**
* Where a timed block sits in its column, after tweening: across as shares of
* the column's width, so a column resizing carries its blocks with it (#184),
* down in the timeline's own units.
*/
@Immutable
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y: Dp, val height: Dp)
/**
* A timed block's placement, tweened rather than jumped. Continuity comes from
@@ -59,17 +248,49 @@ data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion() ||
LocalTimelineZoom.current.isPinching
) {
snap()
} else {
MaterialTheme.motionScheme.fastSpatialSpec()
fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement {
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
val dpSpec: FiniteAnimationSpec<Dp> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
val shareSpec: FiniteAnimationSpec<Float> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
val animatedY by animateDpAsState(y, dpSpec, label = "block-y")
val animatedHeight by animateDpAsState(height, dpSpec, label = "block-height")
return BlockPlacement(animatedX, animatedWidth, animatedY, animatedHeight)
}
/** Seats a block across its [placement]'s share of a [LaneColumn]. */
fun Modifier.laneSlot(placement: BlockPlacement): Modifier =
then(LaneSlot(placement.xFraction, placement.widthFraction))
private data class LaneSlot(val x: Float, val width: Float) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any = this@LaneSlot
}
/**
* A day column's blocks, each measured across the share of the column its
* [laneSlot] gives it; anything else fills the column. The shares are applied
* at layout time, so the column can change width every frame without its
* blocks recomposing.
*/
@Composable
fun LaneColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(content = content, modifier = modifier) { measurables, constraints ->
val width = constraints.maxWidth
val loose = constraints.copy(minWidth = 0, minHeight = 0)
val placed = measurables.map { measurable ->
val slot = measurable.parentData as? LaneSlot
if (slot == null) {
measurable.measure(loose) to 0
} else {
val left = (slot.x * width).roundToInt()
val right = ((slot.x + slot.width) * width).roundToInt()
val laneWidth = (right - left).coerceAtLeast(0)
measurable.measure(loose.copy(minWidth = laneWidth, maxWidth = laneWidth)) to left
}
}
layout(width, constraints.maxHeight) {
placed.forEach { (placeable, x) -> placeable.placeRelative(x, 0) }
}
}
val animatedX by animateDpAsState(x, spec, label = "block-x")
val animatedY by animateDpAsState(y, spec, label = "block-y")
val animatedWidth by animateDpAsState(width, spec, label = "block-width")
val animatedHeight by animateDpAsState(height, spec, label = "block-height")
return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight)
}
@@ -0,0 +1,161 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalDate
/**
* What a calendar screen shows in the top bar, the FAB and the drawer, which the
* host draws once for all of them (#184): switching screens changes what they
* say rather than swapping one bar for another.
*
* @param titleDate the date the title's picker opens on, or null for a title
* that is only a label.
* @param drawerDate the date the drawer's picker opens on.
* @param awayFromToday whether "today" is worth offering.
*/
@Immutable
data class ScreenChrome(
val title: String,
val shortTitle: String = title,
val titleDate: LocalDate?,
val drawerDate: LocalDate,
val awayFromToday: Boolean,
val todayText: String,
)
/** What the chrome does for the screen on show; read when used, so not observed. */
class ScreenActions(
val onToday: () -> Unit,
val onJumpToDate: (LocalDate) -> Unit,
val onCreate: () -> Unit,
)
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
@Stable
class ChromeHost {
var chrome by mutableStateOf<ScreenChrome?>(null)
private set
var actions: ScreenActions? = null
private set
internal fun publish(chrome: ScreenChrome, actions: ScreenActions) {
this.chrome = chrome
this.actions = actions
}
}
/** The host's chrome, and whether the screen reading it is the one on show rather than one leaving. */
class ChromeSlot(val host: ChromeHost, val current: Boolean)
val LocalChromeSlot = staticCompositionLocalOf<ChromeSlot?> { null }
/** Hands [chrome] and [actions] to the host, while this screen is the one on show. */
@Composable
fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
val slot = LocalChromeSlot.current ?: return
if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
}
/** The top bar every calendar screen shares. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarTopBar(
host: ChromeHost,
selectedView: CalendarView,
quickSwitchViews: List<CalendarView>,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
) {
val chrome = host.chrome
TopAppBar(
title = {
if (chrome != null) {
CalendarTitleButton(
title = chrome.title,
currentDate = chrome.titleDate,
onJumpToDate = { host.actions?.onJumpToDate?.invoke(it) },
shortTitle = chrome.shortTitle,
)
}
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
TodayAction(show = showTodayButton, onToday = { host.actions?.onToday?.invoke() })
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
)
},
// Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
}
/** The FAB column every calendar screen shares. */
@Composable
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) {
val chrome = host.chrome ?: return
CalendarFabColumn(
todayVisible = chrome.awayFromToday && !todayInToolbar,
todayText = chrome.todayText,
onToday = { host.actions?.onToday?.invoke() },
onCreate = { host.actions?.onCreate?.invoke() },
)
}
/**
* A calendar screen's body under the shared chrome, on the ground the screen's
* own Scaffold used to give it; the host's Scaffold has already made room for
* the bars, so [content] gets no padding of its own.
*/
@Composable
fun ScreenBody(modifier: Modifier = Modifier, content: @Composable (PaddingValues) -> Unit) {
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
content(PaddingValues())
}
}
@@ -91,7 +91,7 @@ fun CalendarDrawer(
DrawerSectionHeader(stringResource(R.string.view_section))
viewOrder.forEachIndexed { index, view ->
GroupedRow(
title = stringResource(view.labelRes),
title = view.label(),
position = positionOf(index, viewOrder.size),
selected = view == currentView,
minHeight = 56.dp,
@@ -1,17 +1,40 @@
package de.jeanlucmakiola.calendula.ui.common
import android.content.Context
import android.content.Intent
import android.provider.CalendarContract
import android.provider.Settings
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.EditOff
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.EventBusy
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.SyncProblem
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -20,37 +43,168 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
/**
* Full-screen failure state shared by every calendar screen (spec §7).
* One explanation line + one recovery action, never a toast.
* A tonal icon, one headline, one supporting line and one recovery action —
* never a toast.
*/
@Composable
fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
val titleRes = when (reason) {
FailureReason.PermissionRevoked -> R.string.state_failure_permission
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
FailureReason.ProviderUnavailable -> R.string.state_failure_provider
FailureReason.Unknown,
FailureReason.EventNotFound -> R.string.state_failure_unknown
}
val actionRes = when (reason) {
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
else -> R.string.state_retry
fun CalendarFailure(
reason: FailureReason,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val manageCalendars = LocalManageCalendars.current
val copy = failureCopy(reason)
val onAction: () -> Unit = when (reason) {
FailureReason.NoCalendarsConfigured -> {
{ context.startCalendarSetup() }
}
FailureReason.AllCalendarsHidden,
FailureReason.NoImportTarget,
-> manageCalendars ?: onRetry
else -> onRetry
}
Column(
modifier = Modifier
modifier = modifier
.fillMaxSize()
.padding(32.dp),
.padding(horizontal = 32.dp, vertical = 24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
FailureIcon(icon = copy.icon, isError = copy.isError)
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(titleRes),
text = stringResource(copy.title),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
)
Spacer(Modifier.height(24.dp))
FilledTonalButton(onClick = onRetry) {
Text(stringResource(actionRes))
copy.body?.let { body ->
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
)
}
Spacer(Modifier.height(32.dp))
FilledTonalButton(onClick = onAction) {
Text(stringResource(copy.action))
}
}
}
/** The state's mark: the reason's icon in a tonal circle above the headline. */
@Composable
private fun FailureIcon(icon: ImageVector, isError: Boolean) {
val container: Color
val content: Color
if (isError) {
container = MaterialTheme.colorScheme.errorContainer
content = MaterialTheme.colorScheme.onErrorContainer
} else {
container = MaterialTheme.colorScheme.secondaryContainer
content = MaterialTheme.colorScheme.onSecondaryContainer
}
Box(
modifier = Modifier
.size(72.dp)
.background(container, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = content,
modifier = Modifier.size(36.dp),
)
}
}
/**
* What a reason says and offers. Headline and supporting line are separate so
* the explanation reads as body text instead of a headline that runs over three
* lines; [isError] tints the mark for the two reasons that are an actual fault
* rather than a calendar the user can switch back on.
*/
private data class FailureCopy(
val icon: ImageVector,
@StringRes val title: Int,
@StringRes val body: Int?,
@StringRes val action: Int,
val isError: Boolean = false,
)
private fun failureCopy(reason: FailureReason): FailureCopy = when (reason) {
FailureReason.PermissionRevoked -> FailureCopy(
icon = Icons.Outlined.Lock,
title = R.string.state_failure_permission,
body = R.string.state_failure_permission_body,
action = R.string.state_failure_permission_action,
)
FailureReason.NoCalendarsConfigured -> FailureCopy(
icon = Icons.Outlined.CalendarMonth,
title = R.string.state_failure_no_calendars,
body = R.string.state_failure_no_calendars_body,
action = R.string.state_failure_no_calendars_action,
)
FailureReason.AllCalendarsHidden -> FailureCopy(
icon = Icons.Outlined.VisibilityOff,
title = R.string.state_failure_all_hidden,
body = R.string.state_failure_all_hidden_body,
action = R.string.state_failure_all_hidden_action,
)
FailureReason.NoImportTarget -> FailureCopy(
icon = Icons.Outlined.EditOff,
title = R.string.state_failure_no_import_target,
body = R.string.state_failure_no_import_target_body,
action = R.string.state_failure_all_hidden_action,
)
FailureReason.EventNotFound -> FailureCopy(
icon = Icons.Outlined.EventBusy,
title = R.string.state_failure_event_not_found,
body = R.string.state_failure_event_not_found_body,
action = R.string.state_retry,
)
FailureReason.ProviderUnavailable -> FailureCopy(
icon = Icons.Outlined.SyncProblem,
title = R.string.state_failure_provider,
body = R.string.state_failure_provider_body,
action = R.string.state_retry,
isError = true,
)
FailureReason.Unknown -> FailureCopy(
icon = Icons.Outlined.ErrorOutline,
title = R.string.state_failure_unknown,
body = null,
action = R.string.state_retry,
isError = true,
)
}
/** Keeps the headline and its supporting line at a readable measure. */
private val TEXT_MAX_WIDTH = 320.dp
/**
* Opens Settings → Calendars, the only screen that can switch a calendar back
* on. Null outside the calendar host — screens without it never raise the
* reason that needs it, and fall back to their own retry.
*/
val LocalManageCalendars = compositionLocalOf<(() -> Unit)?> { null }
/**
* Open the system screen for putting a calendar on the device: the account
* chooser filtered to calendar providers, falling back to account settings
* where no chooser resolves. Both are system components, so nothing here needs
* a `<queries>` entry.
*/
private fun Context.startCalendarSetup() {
val intents = listOf(
Intent(Settings.ACTION_ADD_ACCOUNT)
.putExtra(Settings.EXTRA_AUTHORITIES, arrayOf(CalendarContract.AUTHORITY)),
Intent(Settings.ACTION_SYNC_SETTINGS),
)
intents.firstOrNull { runCatching { startActivity(it) }.isSuccess }
}
@@ -0,0 +1,57 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.VectorConverter
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.lerp
import kotlinx.coroutines.flow.collectLatest
import kotlin.math.floor
/**
* One height for every page of something that varies from page to page — the
* timelines' all-day strip, the split grid's rows — blended between the two
* pages a swipe sits between, so what sits below it moves with the finger rather
* than jumping on settle. It follows the swipe directly and only springs when a
* page's own height changes at rest. [fallback] stands in until the pages have
* reported theirs, so it doesn't open from zero.
*
* @param heights each composed page's own height, by page index.
*/
@Composable
fun pagedHeight(pagerState: PagerState, heights: Map<Int, Dp>, fallback: Dp): Dp {
val currentFallback by rememberUpdatedState(fallback)
val target by remember(pagerState, heights) {
derivedStateOf {
val position = pagerState.currentPage + pagerState.currentPageOffsetFraction
val from = floor(position).toInt()
val a = heights[from]
val b = heights[from + 1]
lerp(a ?: b ?: currentFallback, b ?: a ?: currentFallback, position - from)
}
}
val height = remember { Animatable(target, Dp.VectorConverter) }
LaunchedEffect(height) {
snapshotFlow { target to pagerState.isScrollInProgress }.collectLatest { (h, scrolling) ->
if (scrolling) height.snapTo(h) else height.animateTo(h)
}
}
return height.value
}
/** A deliberate jump (Today, jump-to-date): animated, or snapped under reduced motion. */
suspend fun PagerState.jumpToPage(
page: Int,
reduceMotion: Boolean,
spec: FiniteAnimationSpec<Float>,
) {
if (reduceMotion) scrollToPage(page) else animateScrollToPage(page, animationSpec = spec)
}
@@ -116,7 +116,7 @@ private fun CalendarPickerGroup(
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id == selectedId
GroupedRow(
title = calendar.displayName,
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
selected = isSelected,
leading = { CalendarColorChip(calendar.color) },
@@ -1,79 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
/**
* Drag distance that turns a calendar page, shared by the month, week and day
* views so all three answer a swipe at the same point.
*
* It was 6dp once, which is inside the distance a tap wanders: brushing the grid
* changed the month, and a page that turns on an unintended gesture reads as the
* animation misfiring rather than as the gesture being over-eager.
*/
val CALENDAR_SWIPE_THRESHOLD = 24.dp
/**
* The whole-page horizontal swipe: one page per gesture, committed **the moment
* the drag clears [CALENDAR_SWIPE_THRESHOLD]** rather than when the finger lifts.
*
* Waiting for the lift meant the page sat still under a finger that had already
* travelled far enough to ask for it, and the answer only arrived once you let
* go — which reads as the view being slow rather than as a deliberate commit.
* Firing on the threshold is what makes the gesture feel like it is being
* followed. The trade is that a drag can no longer be taken back by dragging the
* other way; in practice, once you have moved 24dp deliberately you meant it, and
* the page you land on is one swipe back.
*
* Deliberately **horizontal-only**. The week and day timelines scroll vertically
* underneath this, and a two-dimensional detector here would claim those drags
* before the inner scroll ever saw them. As it is, a horizontal drag crosses this
* detector's slop while a vertical one is consumed below, and the two coexist.
* (The month view's split style needs a vertical axis as well, so it keeps its
* own axis-locking detector rather than using this.)
*/
@Composable
fun rememberCalendarPageSwipe(
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
): Modifier {
val threshold = with(LocalDensity.current) { CALENDAR_SWIPE_THRESHOLD.toPx() }
return Modifier.pointerInput(onSwipeNext, onSwipePrev) {
var accum = 0f
// One page per gesture: without this a long drag would keep re-firing
// every time the accumulator crossed the threshold again.
var fired = false
detectHorizontalDragGestures(
onDragStart = {
accum = 0f
fired = false
},
onDragEnd = {
accum = 0f
fired = false
},
onDragCancel = {
accum = 0f
fired = false
},
onHorizontalDrag = { _, drag ->
accum += drag
if (!fired) {
val commit = when {
accum < -threshold -> onSwipeNext
accum > threshold -> onSwipePrev
else -> null
}
if (commit != null) {
fired = true
commit()
}
}
},
)
}
}
@@ -0,0 +1,57 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
/**
* The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in
* a 16sp line box — room a label wants when it stands on its own, and close to
* a fifth of an event row when it doesn't. The outer edges only, so a wrapped
* title keeps its interior line spacing (#190).
*
* Trimmed rather than set to a smaller line height: the font picker can load a
* serif, a monospace or a file of the user's own, and a line height under a
* face's own ascent and descent overlaps its lines. There is nothing to trim
* below that, so this is safe whatever font is chosen.
*/
private val TrimmedLines = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.Both,
)
/** Ascenders and descenders both, so a line is measured at its full extent. */
private const val LINE_SAMPLE = "Ag"
/** [this] with Material's outer leading trimmed — see [TrimmedLines]. */
fun TextStyle.trimmedLines(): TextStyle = copy(lineHeightStyle = TrimmedLines)
/**
* [style] with its wrapped lines packed onto the face's own extent instead of
* Material's line box.
*
* [trimmedLines] takes the leading off the outer edges of a run of text; this
* takes it from between the lines as well, which is the half a wrapped event
* title pays for twice over. The line height is *measured* from the font rather
* than picked, so it lands exactly on the face's ascent-plus-descent and can
* never be short enough to overlap — whatever the font picker has loaded (#190).
*/
@Composable
fun rememberPackedLines(style: TextStyle): TextStyle {
val line = rememberTrimmedLineHeight(style)
return with(LocalDensity.current) { style.trimmedLines().copy(lineHeight = line.toSp()) }
}
/** What one trimmed line of [style] actually draws in. */
@Composable
fun rememberTrimmedLineHeight(style: TextStyle): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
return remember(style, density, measurer) {
with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() }
}
}
@@ -1,6 +1,15 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
@@ -9,16 +18,19 @@ import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalDate
@@ -29,19 +41,20 @@ import kotlinx.datetime.LocalDate
* drawer uses and reporting the chosen day through [onJumpToDate].
*
* [currentDate] seeds the picker with whatever the bar is currently naming (the
* visible day, week start or month anchor).
* visible day, week start or month anchor); null makes the title a plain label,
* as the agenda's is, and the caret folds away.
*
* The row keeps its own 8.dp inset rather than shifting back onto the app bar's
* start alignment: M3 places the title flush against the navigation icon's
* trailing edge, and the title is hit-tested on top of it, so a negative offset
* would swallow taps meant for the menu button.
* [shortTitle] replaces [title] when the full one does not fit the width the app
* bar hands the title slot (#165). Either way the line clamps to one and
* ellipsises, so pass a [shortTitle] wherever there is something left to drop.
*/
@Composable
fun CalendarTitleButton(
title: String,
currentDate: LocalDate,
currentDate: LocalDate?,
onJumpToDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
shortTitle: String = title,
) {
var showDatePicker by rememberSaveable { mutableStateOf(false) }
@@ -50,25 +63,56 @@ fun CalendarTitleButton(
.heightIn(min = 48.dp)
.clip(MaterialTheme.shapes.large)
.clickable(
enabled = currentDate != null,
onClickLabel = stringResource(R.string.drawer_jump_to_date),
role = Role.Button,
) { showDatePicker = true }
.padding(horizontal = 8.dp),
.padding(horizontal = AppBarSpacing.TitleInset),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f, fill = false),
)
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
val style = MaterialTheme.typography.titleLarge
BoxWithConstraints(modifier = Modifier.weight(1f, fill = false)) {
val measurer = rememberTextMeasurer()
val shown = remember(title, shortTitle, style, constraints.maxWidth, measurer) {
titleFor(
title = title,
shortTitle = shortTitle,
titleWidth = measurer.measure(title, style).size.width,
availableWidth = constraints.maxWidth,
)
}
// Cross-fades, so switching views or paging turns the title over
// rather than snapping it (#184).
val fadeSpec = rememberCalendarFadeSpec()
AnimatedContent(
targetState = shown,
transitionSpec = {
(fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false))
},
contentAlignment = Alignment.CenterStart,
) { text ->
Text(
text = text,
style = style,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
AnimatedVisibility(
visible = currentDate != null,
enter = fadeIn(rememberCalendarFadeSpec()) + expandHorizontally(),
exit = fadeOut(rememberCalendarFadeSpec()) + shrinkHorizontally(),
) {
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (showDatePicker) {
if (showDatePicker && currentDate != null) {
CalendarDatePickerDialog(
initial = currentDate,
onConfirm = {
@@ -79,3 +123,11 @@ fun CalendarTitleButton(
)
}
}
/** Falls back to [shortTitle] when [title] is wider than [availableWidth] (#165). */
internal fun titleFor(
title: String,
shortTitle: String,
titleWidth: Int,
availableWidth: Int,
): String = if (titleWidth <= availableWidth) title else shortTitle
@@ -1,13 +1,7 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.animation.core.spring
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -19,12 +13,12 @@ import androidx.compose.ui.unit.IntOffset
* the reduce-motion check — now live in floret-kit's identity module
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
* here is only what's specific to paging the calendar grid: the directional
* month/week/day slide and the specs that feed it.
* here is only what's specific to paging the calendar grid: the specs its
* pagers settle with, and that the view switch reuses.
*/
/**
* The M3 Expressive spatial spring used for the month/week/day slide: the
* The M3 Expressive spatial spring the calendar's motion is built on: the
* *default* spring-physics spec from the active motion scheme, rather than a
* fixed easing curve.
*
@@ -40,6 +34,12 @@ import androidx.compose.ui.unit.IntOffset
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.defaultSpatialSpec()
/** The same spring as [rememberCalendarSlideSpec], for a pager settling onto its page. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberCalendarPageSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.defaultSpatialSpec()
/**
* The effects spec from the active motion scheme, for the opacity half of the
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
@@ -55,48 +55,17 @@ fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.defaultEffectsSpec()
/**
* Navigating between adjacent months/weeks/days, as M3's shared-axis X: the
* outgoing page slides and fades one way while the incoming one arrives from the
* other, position on a spring and opacity on an easing curve.
*
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
* (e.g. "today"); a jump reuses the forward direction.
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
* @param fadeSpec effects spec for the opacity half, and for the whole
* transition under reduced motion; typically
* [rememberCalendarFadeSpec].
* @param reduceMotion when true, drop the movement and cross-fade alone.
* A switch between views (#184): the default spatial spring, a little softer, as
* the whole screen reshapes rather than a page sliding by; softer still when
* [long], for a change the eye has further to follow.
*/
fun calendarSlideTransition(
slideDir: Int,
spec: FiniteAnimationSpec<IntOffset>,
fadeSpec: FiniteAnimationSpec<Float>,
reduceMotion: Boolean,
): ContentTransform {
if (reduceMotion) {
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
}
val dir = if (slideDir == 0) 1 else slideDir
return ContentTransform(
targetContentEnter =
slideInHorizontally(spec) { w -> dir * w / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec),
initialContentExit =
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
// AnimatedContent clips to the animating container by default, which
// shears the pages against the viewport edge as they pass. There is no
// size change here to contain — both pages are the same grid.
sizeTransform = SizeTransform(clip = false),
@Composable
fun <T> rememberViewSwitchSpec(long: Boolean = false): FiniteAnimationSpec<T> =
spring(
dampingRatio = VIEW_SWITCH_DAMPING,
stiffness = if (long) LONG_VIEW_SWITCH_STIFFNESS else VIEW_SWITCH_STIFFNESS,
)
}
/**
* How far a page travels, as a fraction of the container width.
*
* A full width was the obvious reading of "paging", but the two pages are
* stacked and both opaque, so a full-width slide showed one grid racing across
* another — the movement carried the whole transition and had a long way to go.
* Under M3's shared-axis pattern the offset only has to *hint* the direction
* while the cross-fade does the swapping, so a fifth of the width is plenty and
* leaves nothing skating past.
*/
private const val SLIDE_TRAVEL_DIVISOR = 5
private const val VIEW_SWITCH_DAMPING = 0.9f
private const val VIEW_SWITCH_STIFFNESS = 450f
private const val LONG_VIEW_SWITCH_STIFFNESS = 340f
@@ -1,37 +1,53 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarViewDay
import androidx.compose.material.icons.filled.CalendarViewMonth
import androidx.compose.material.icons.filled.CalendarViewWeek
import androidx.compose.material.icons.filled.ViewAgenda
import androidx.compose.material.icons.filled.ViewColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
/** The top-level calendar views the user can switch between (spec M1). */
enum class CalendarView {
Month,
Week,
MultiDay,
Day,
Agenda,
}
/** Switcher label, shared by the top-bar pill and the drawer's View section. */
@get:StringRes
val CalendarView.labelRes: Int
get() = when (this) {
CalendarView.Month -> R.string.view_month
CalendarView.Week -> R.string.view_week
CalendarView.Day -> R.string.view_day
CalendarView.Agenda -> R.string.view_agenda
/** How many days the multi-day view shows (#193), provided once at the app root. */
val LocalMultiDayCount = staticCompositionLocalOf { SettingsPrefs.DEFAULT_MULTI_DAY_COUNT }
/**
* The view's name, shared by the top-bar pill, the drawer and Settings. The
* multi-day view is named after its day count ("3 days").
*/
@Composable
fun CalendarView.label(): String = when (this) {
CalendarView.Month -> stringResource(R.string.view_month)
CalendarView.Week -> stringResource(R.string.view_week)
CalendarView.MultiDay -> {
val count = LocalMultiDayCount.current
pluralStringResource(R.plurals.view_multi_day, count, count)
}
CalendarView.Day -> stringResource(R.string.view_day)
CalendarView.Agenda -> stringResource(R.string.view_agenda)
}
/** Leading icon for the view in the drawer's View section. */
val CalendarView.icon: ImageVector
get() = when (this) {
CalendarView.Month -> Icons.Filled.CalendarViewMonth
CalendarView.Week -> Icons.Filled.CalendarViewWeek
CalendarView.MultiDay -> Icons.Filled.ViewColumn
CalendarView.Day -> Icons.Filled.CalendarViewDay
CalendarView.Agenda -> Icons.Filled.ViewAgenda
}
@@ -41,7 +57,13 @@ val CalendarView.icon: ImageVector
* through these in order.
*/
val IMPLEMENTED_VIEWS: List<CalendarView> =
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day, CalendarView.Agenda)
listOf(
CalendarView.Month,
CalendarView.Week,
CalendarView.MultiDay,
CalendarView.Day,
CalendarView.Agenda,
)
/** Next view in [available], wrapping around. Falls back to Month if absent. */
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
@@ -57,7 +79,8 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
* implemented view — the settings screen reorders the whole set — while [cycle]
* is the subset the pill actually steps through, in [order]. The navigation
* drawer keeps its own separate order and always lists every view, so a view
* disabled here stays reachable there.
* disabled here stays reachable there — including when [cycle] is emptied and
* the pill disappears altogether.
*/
data class QuickSwitchConfig(
val order: List<CalendarView>,
@@ -67,14 +90,15 @@ data class QuickSwitchConfig(
val cycle: List<CalendarView> get() = order.filter { it in enabled }
companion object {
/**
* Fewest views that keep the switch meaningful. A single target is not a
* switch, so below this the pill is hidden rather than special-cased (#150);
* the drawer still reaches every view.
*/
const val MIN_CYCLE = 2
/** All views, in default order, all enabled. */
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
/**
* Fewest views that keep the switch meaningful — a "switch" needs at
* least two targets, so the settings screen blocks disabling below this.
*/
const val MIN_ENABLED = 2
}
}
@@ -105,3 +129,16 @@ fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarVie
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
fun List<CalendarView>.drillToDay(): List<CalendarView> =
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
/** The screens the views are drawn by: the day, multi-day and week views share one timeline (#184). */
enum class ViewScreen { Month, Timeline, Agenda }
val CalendarView.screen: ViewScreen
get() = when (this) {
CalendarView.Month -> ViewScreen.Month
CalendarView.Agenda -> ViewScreen.Agenda
CalendarView.Week, CalendarView.MultiDay, CalendarView.Day -> ViewScreen.Timeline
}
/** Push the week view over the month after a week-number tap. */
fun List<CalendarView>.drillToWeek(): List<CalendarView> =
if (lastOrNull() == CalendarView.Week) this else this + CalendarView.Week
@@ -0,0 +1,104 @@
package de.jeanlucmakiola.calendula.ui.common
import android.content.ClipData
import android.os.Build
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.onLongClick
import androidx.compose.ui.semantics.semantics
import de.jeanlucmakiola.calendula.R
import kotlinx.coroutines.launch
/** Puts one labelled field on the clipboard. */
fun interface FieldCopier {
operator fun invoke(label: String, text: String)
}
/**
* A [FieldCopier] that confirms the copy only where the system doesn't (#195).
*
* Android 13 raises its own clipboard chip for every copy, so a snackbar on top
* of it reads as the app having done the job twice. A failure is always worth a
* word, though: the clipboard can refuse a very long description outright.
*/
@Composable
fun rememberFieldCopier(snackbarHostState: SnackbarHostState): FieldCopier {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val confirmation = stringResource(R.string.field_copied)
val failure = stringResource(R.string.field_copy_failed)
return remember(clipboard, scope, snackbarHostState, confirmation, failure) {
FieldCopier { label, text ->
scope.launch {
val message = runCatching {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, text)))
}.fold(
onSuccess = {
confirmation.takeIf {
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
}
},
onFailure = { failure },
)
if (message != null) {
// Copying twice in a row shouldn't queue two four-second
// confirmations — the newest one wins.
snackbarHostState.currentSnackbarData?.dismiss()
snackbarHostState.showSnackbar(message)
}
}
}
}
}
/**
* Long-press to copy [text] whole, filed on the clipboard under [label].
*
* [onTap] carries a field's existing tap action through; a field without one
* stays unclickable rather than growing a ripple that leads nowhere, and gets
* its long press announced through semantics instead. That branch merges the
* node it sits on, so a screen reader lands on the field itself and finds the
* action there — a bare container is never focused.
*/
@Composable
fun Modifier.copyOnLongPress(
label: String,
text: String,
copy: FieldCopier,
onTap: (() -> Unit)? = null,
): Modifier {
val actionLabel = stringResource(R.string.field_copy_action)
return if (onTap != null) {
combinedClickable(
onClick = onTap,
onLongClickLabel = actionLabel,
onLongClick = { copy(label, text) },
)
} else {
val haptics = LocalHapticFeedback.current
pointerInput(label, text, copy) {
detectTapGestures(
onLongPress = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
copy(label, text)
},
)
}.semantics(mergeDescendants = true) {
onLongClick(actionLabel) {
copy(label, text)
true
}
}
}
}
@@ -0,0 +1,165 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse
import de.jeanlucmakiola.calendula.domain.isDeclined
/** Stroke an invitation you have not answered is outlined with. */
val EVENT_OUTLINE_WIDTH = 1.dp
/**
* The surface an event chip is drawn on, which an outlined one fills itself with
* so it reads as the background showing through rather than as a pale block laid
* over it. Defaults to the `surfaceContainer` the month cells and the timeline
* columns carry; the all-day strips sit on plain `surface` and a floating drag
* copy wants a lifted one, so both provide their own.
*/
val LocalChipGround = compositionLocalOf { Color.Unspecified }
/**
* Which edges of a chip the event runs past. Those edges are squared by
* [monthBarShape] / [timedBlockShape] so the cut reads as "this carries on", and
* an outline has to leave them open for the same reason — a stroke all the way
* round would close a bar carried across a week boundary into two boxes.
*/
@Immutable
data class ChipCuts(
val start: Boolean = false,
val end: Boolean = false,
val top: Boolean = false,
val bottom: Boolean = false,
)
/** [ChipCuts] for a month or all-day bar, which is only ever cut left and right. */
fun monthBarCuts(continuesLeft: Boolean, continuesRight: Boolean): ChipCuts =
ChipCuts(start = continuesLeft, end = continuesRight)
/** [ChipCuts] for a timed block, which is only ever cut top and bottom. */
fun timedBlockCuts(continuesBefore: Boolean, continuesAfter: Boolean): ChipCuts =
ChipCuts(top = continuesBefore, bottom = continuesAfter)
/**
* How one event's chip or block is painted, which turns on your answer to its
* invitation (#180, #230).
*
* An invitation you have not answered is drawn as an outline: its calendar's
* colour on the border and on the title, over the surface the chip sits on. So
* it holds a chip's shape and a chip's weight without claiming the filled
* container an event you are going to gets. A declined one keeps the fill and
* its strike-through.
*/
@Immutable
data class EventPaint(
val fill: Color,
/** Null for a filled chip; the border colour when the chip is outlined. */
val outline: Color?,
val titleInk: Color,
val secondaryInk: Color,
val decoration: TextDecoration?,
/**
* Weight to set the title at, or null to keep whatever the surface's own
* text style carries.
*/
val titleWeight: FontWeight?,
)
/** [EventPaint] for [event] on a [dark] scheme. */
@Composable
fun eventPaint(event: EventInstance, dark: Boolean): EventPaint {
val soften = LocalSoftenColors.current
if (event.response == EventResponse.Invited) {
// Harmonised even when the setting is off. Raw mode exists so a filled
// container matches what the sync source paints, and an outlined chip
// has no container — what it has is coloured *text*, which needs the
// lightness pinned against the surface or a pale calendar goes
// unreadable (eventTone returns the raw colour verbatim otherwise).
val accent = eventAccent(event.color, dark, soften = true)
val ground = LocalChipGround.current.takeIf { it != Color.Unspecified }
?: MaterialTheme.colorScheme.surfaceContainer
return EventPaint(
fill = ground,
outline = accent,
titleInk = accent,
// A neutral token rather than the accent faded: the time is the
// smallest text on the chip, and an alpha step off an accent that is
// itself only just clear of the surface is where legibility goes.
secondaryInk = MaterialTheme.colorScheme.onSurfaceVariant,
decoration = null,
// The label styles' medium weight is set to carry ink on a filled
// container. In a calendar colour on a plain one it thickens into
// something harder to read, so step it back to regular.
titleWeight = FontWeight.Normal,
)
}
val fill = eventFill(event.color, dark, soften)
return EventPaint(
fill = fill,
outline = null,
titleInk = eventInk(fill, alpha = TITLE_INK_ALPHA),
secondaryInk = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
decoration = declinedDecoration(event.isDeclined),
titleWeight = null,
)
}
/** [this] set at the weight [paint] asks a title for, for measuring and for drawing alike. */
fun TextStyle.withTitleWeight(paint: EventPaint): TextStyle =
if (paint.titleWeight == null) this else copy(fontWeight = paint.titleWeight)
/** Seats a chip or block on [shape]: its fill, plus the border when it has one. */
fun Modifier.eventSurface(
paint: EventPaint,
shape: Shape,
cuts: ChipCuts = ChipCuts(),
): Modifier {
val filled = background(paint.fill, shape)
val outline = paint.outline ?: return filled
return filled.clip(shape).drawBehind { drawChipOutline(outline, cuts) }
}
/**
* The border, stroked as one round-rect whose edges run past the chip on every
* side the event continues over. Clipped to the chip's own shape, so those
* strokes — and the corners that would have turned back in — fall outside and
* the edge stays open.
*/
private fun DrawScope.drawChipOutline(color: Color, cuts: ChipCuts) {
val stroke = EVENT_OUTLINE_WIDTH.toPx()
val radius = EVENT_CHIP_CORNER.toPx()
// Far enough out that the corner arc clears the clip too, not just the edge.
val bleed = radius + stroke
val rtl = layoutDirection == LayoutDirection.Rtl
val leftCut = if (rtl) cuts.end else cuts.start
val rightCut = if (rtl) cuts.start else cuts.end
val left = if (leftCut) -bleed else stroke / 2f
val top = if (cuts.top) -bleed else stroke / 2f
val right = if (rightCut) size.width + bleed else size.width - stroke / 2f
val bottom = if (cuts.bottom) size.height + bleed else size.height - stroke / 2f
drawRoundRect(
color = color,
topLeft = Offset(left, top),
size = Size(right - left, bottom - top),
cornerRadius = CornerRadius(radius),
style = Stroke(width = stroke),
)
}
@@ -0,0 +1,77 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.floret.locale.currentLocale
private val TIME_WEIGHT = FontWeight.Normal
private val TIME_TRACKING = 0.sp
private val TIME_SPAN = SpanStyle(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING)
/**
* Ink for an event's title, against [SECONDARY_INK_ALPHA] for the time beside or
* beneath it. Full, so the pair separates on a typeface with no medium weight to
* step down from — Atkinson Hyperlegible and JetBrains Mono ship regular and bold
* only, and a user's imported font a single face, so [asEventTime] is a no-op
* there and the ink is the whole difference (#219).
*/
const val TITLE_INK_ALPHA = 1f
/**
* [this] set as an event's time rather than as its title: the label styles' medium
* weight and 0.5sp of tracking left a time reading as part of the title next to it.
*/
fun TextStyle.asEventTime(): TextStyle =
copy(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING)
/** Text reading [time], set apart in [timeInk], before [title]. */
fun inlineTimeLabel(time: String?, title: String, timeInk: Color): AnnotatedString =
buildAnnotatedString {
if (time != null) {
withStyle(TIME_SPAN.copy(color = timeInk)) { append(time) }
append(" ")
}
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. */
private const val SAMPLE_HOUR = 12
private const val SAMPLE_MINUTE = 45
/**
* The narrowest run of [style] text that may carry a time in front of a title:
* wide enough for the widest time in the current convention plus [TITLE_SAMPLE].
*
* 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
fun rememberInlineTimeWidth(style: TextStyle): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val locale = currentLocale()
val sample = formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, LocalUse24HourFormat.current, locale)
return remember(sample, style, density, measurer) {
val label = inlineTimeLabel(sample, TITLE_SAMPLE, Color.Unspecified)
with(density) { measurer.measure(label, style).size.width.toDp() }
}
}
@@ -0,0 +1,37 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
/** How an event chip's title should overflow: what to pass to `Text`. */
data class EventTitleOverflow(val overflow: TextOverflow, val softWrap: Boolean)
/**
* Overflow for an event chip's title (#164). Chips are narrow enough that the
* "…" costs a couple of readable characters, so the title runs to the chip's
* edge and clips instead — mid-glyph on one line, at the last whole word it
* could fit on several.
*
* One case keeps the ellipsis: a single **[rtl]** line. With `softWrap` off
* Compose lays the line out at its full intrinsic width and clips to the node's
* left edge, which in RTL is the *end* of the string — an Arabic title would
* lose its beginning. The ellipsis truncates at the logical end in both
* directions. Wrapping needs `softWrap` on, which clips the right end either
* way, so more than one line needs no such exception.
*/
fun eventTitleOverflowFor(rtl: Boolean, singleLine: Boolean): EventTitleOverflow =
when {
!singleLine -> EventTitleOverflow(TextOverflow.Clip, softWrap = true)
rtl -> EventTitleOverflow(TextOverflow.Ellipsis, softWrap = true)
else -> EventTitleOverflow(TextOverflow.Clip, softWrap = false)
}
/** [eventTitleOverflowFor] against the current layout direction. */
@Composable
fun eventTitleOverflow(singleLine: Boolean = true): EventTitleOverflow =
eventTitleOverflowFor(
rtl = LocalLayoutDirection.current == LayoutDirection.Rtl,
singleLine = singleLine,
)
@@ -0,0 +1,88 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Whether the week/day timeline seats each hour in its own cell, from the
* `show_hour_lines` preference. Provided once at the app root (like
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
* Defaults to off — the historical flat column.
*/
val LocalShowHourGrid = staticCompositionLocalOf { false }
/** Gap between two hour cells, matching the month grid's gap between day cells. */
val HOUR_CELL_GAP = 2.dp
/** Half a gap: what a cell — and a block seated in it — gives up at each edge. */
val HOUR_CELL_INSET = HOUR_CELL_GAP / 2
/**
* The half-gap a block seated in the grid gives up at one of its ends, so an
* on-the-hour event fills its cell instead of overhanging the seam into its
* neighbours'. Zero with the grid off, and zero at an end [cut] at midnight:
* that edge is squared off against the column's own, and stopping a half-gap
* short of it would leave the block floating there rather than running off.
*/
fun hourCellBlockInset(show: Boolean, cut: Boolean): Dp =
if (show && !cut) HOUR_CELL_INSET else 0.dp
/**
* Corner radius of an hour cell: the event chip's own, so the grid never rounds
* harder than the blocks it seats. The month grid's 12dp belongs to a cell many
* times the size — on an hour cell it out-rounds its own content.
*/
private val HOUR_CELL_CORNER = EVENT_CHIP_CORNER
/**
* Radius an hour cell of [cellHeight] by [cellWidth] pixels may round to. The
* hour pitch runs from a fit-the-day sliver up to [MAX_PINCH_HOUR_HEIGHT] and a
* week column is a seventh of the screen, so the radius is held to half the
* shorter side — the point past which the corners would meet and the cell turn
* into a lozenge.
*/
internal fun hourCellRadiusPx(cellHeight: Float, cellWidth: Float, maxRadius: Float): Float =
minOf(maxRadius, cellHeight / 2f, cellWidth / 2f).coerceAtLeast(0f)
/**
* Seat each of the day's 24 hours in its own rounded cell when [show] is true,
* so the hour boundary reads as a seam between two surfaces rather than a line
* drawn across one. Applied to a day column's content, so the cells sit over the
* column background but beneath the event blocks — blocks stay in a continuous
* coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel
* height; [color] is resolved by the caller from the theme.
*/
fun Modifier.hourGridCells(
show: Boolean,
hourHeightPx: Float,
color: Color,
): Modifier =
if (!show) {
this
} else {
drawBehind {
val inset = HOUR_CELL_INSET.toPx()
val cellHeight = hourHeightPx - inset * 2f
if (cellHeight <= 0f) return@drawBehind
val radius = CornerRadius(
hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()),
)
val cellSize = Size(size.width, cellHeight)
val top = inset
for (hour in 0 until 24) {
drawRoundRect(
color = color,
topLeft = Offset(0f, hour * hourHeightPx + top),
size = cellSize,
cornerRadius = radius,
)
}
}
}
@@ -21,6 +21,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -72,6 +73,11 @@ fun HourGutter(
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
label = "hourLabelAlpha",
)
// Each label straddles the boundary it names, so it lines up with the seam
// the hour grid leaves there instead of hanging below it. Derived from the
// label's own line height, so it holds at any font scale.
val labelStyle = MaterialTheme.typography.labelSmall
val labelLift = with(LocalDensity.current) { labelStyle.lineHeight.toDp() } / 2
Box(
modifier = modifier
@@ -90,12 +96,12 @@ fun HourGutter(
if (h > 0) {
Text(
text = formatHourLabel(h, use24Hour, locale),
style = MaterialTheme.typography.labelSmall,
style = labelStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant
.copy(alpha = hourAlpha),
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = (-6).dp),
.offset(y = -labelLift),
)
}
}
@@ -1,38 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
/**
* Whether the week/day timeline draws an hour separator line, from the
* `showHourLines` preference. Provided once at the app root (like
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
* Defaults to off — the historical clean look.
*/
val LocalShowHourLines = staticCompositionLocalOf { false }
/**
* Draw a faint separator line at the top of each hour (1..23) when [show] is
* true. Applied to a day column's content so each line sits over the column's
* background but beneath the event blocks. [hourHeightPx] is one hour's pixel
* height; [color] is resolved by the caller from the theme.
*/
fun Modifier.hourSeparatorLines(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
if (!show) {
this
} else {
drawBehind {
for (hour in 1 until 24) {
val y = hour * hourHeightPx
drawLine(
color = color,
start = Offset(0f, y),
end = Offset(size.width, y),
strokeWidth = 1f,
)
}
}
}
@@ -0,0 +1,328 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/** Thickness of the out-of-view edge bar. */
private val EDGE_BAR_THICKNESS = 3.dp
/** Band the bar sits in, and the room a revealed event keeps from the edge. */
private val EDGE_TOUCH_HEIGHT = 24.dp
/** How far from the viewport's edge a tap on empty column still hits the bar. */
private val EDGE_TAP_REACH = 16.dp
/**
* How far the bar sits in from the viewport's edges — enough to clear the
* timeline's rounded 16dp corners on the outer columns.
*/
private val EDGE_BAR_VERTICAL_INSET = 4.dp
private val EDGE_BAR_HORIZONTAL_INSET = 6.dp
/** Distance outside the viewport over which a bar fades before its event appears. */
private val EDGE_FADE_LEAD = 24.dp
/** The nearest timed blocks wholly above and below a column's viewport. */
@Immutable
data class OutOfViewEvents(val above: TimedBlock?, val below: TimedBlock?) {
companion object {
val None = OutOfViewEvents(null, null)
}
}
/** Which edge of the viewport a hidden event sits past. */
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]
* (minutes of the day), nearest first. A block leaves this set as soon as any
* part of its rendered height enters the viewport.
*/
fun outOfViewEvents(
blocks: List<TimedBlock>,
topMin: Float,
bottomMin: Float,
): OutOfViewEvents {
val above = blocks
.filter { renderedEndMin(it) <= topMin }
.maxWithOrNull(compareBy<TimedBlock>({ renderedEndMin(it) }, { it.startMin }))
val below = blocks
.filter { it.startMin >= bottomMin }
.minWithOrNull(compareBy<TimedBlock>({ it.startMin }, { -renderedEndMin(it) }))
return OutOfViewEvents(above, below)
}
/**
* The scroll offset that brings [block] into a [viewportPx]-tall viewport from
* past [edge], with [marginPx] of room so it clears the edge bar. An event above
* lands with its start at the top; one below lands whole at the bottom when it
* fits, and with its start at the top when it does not.
*/
fun revealScrollTarget(
block: TimedBlock,
edge: ViewportEdge,
hourPx: Float,
viewportPx: Int,
marginPx: Float,
maxScroll: Int,
): Int {
val startPx = block.startMin / 60f * hourPx
val endPx = renderedEndMin(block) / 60f * hourPx
val target = when (edge) {
ViewportEdge.Top -> startPx - marginPx
ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx)
}
return target.roundToInt().coerceIn(0, maxOf(maxScroll, 0))
}
/**
* The edge whose bar a tap at [y] (content px) lands on, if that bar is showing
* and the tap is within [reachPx] of the viewport's edge.
*/
fun tappedEdge(
events: OutOfViewEvents,
y: Float,
viewportTop: Float,
viewportPx: Int,
reachPx: Float,
): ViewportEdge? = when {
events.above != null && y - viewportTop <= reachPx -> ViewportEdge.Top
events.below != null && viewportTop + viewportPx - y <= reachPx -> ViewportEdge.Bottom
else -> null
}
/** Fade while the event is still outside; never draw its bar at the boundary. */
internal fun edgeBarAlpha(gapPx: Float, fadeLeadPx: Float): Float = when {
gapPx <= 0f -> 0f
fadeLeadPx <= 0f -> 1f
else -> (gapPx / fadeLeadPx).coerceIn(0f, 1f)
}
/**
* The out-of-view state of one day column (#188): which events hide past its
* edges, and the scroll that reveals them.
*
* The bars take no touches themselves, so event blocks under them keep their
* taps and drags; the column's own tap handler offers taps to [tap] first.
*/
@Stable
class OutOfViewEdges internal constructor(
private val scrollState: ScrollState,
private val scope: CoroutineScope,
) {
internal var blocks by mutableStateOf(emptyList<TimedBlock>())
internal var hourPx by mutableFloatStateOf(0f)
internal var fadeLeadPx by mutableFloatStateOf(0f)
internal var marginPx by mutableFloatStateOf(0f)
internal var reachPx by mutableFloatStateOf(0f)
internal var reduceMotion = false
internal var revealSpec: FiniteAnimationSpec<Float>? = null
val events: OutOfViewEvents by derivedStateOf {
val viewport = scrollState.viewportSize
if (viewport <= 0 || hourPx <= 0f) {
OutOfViewEvents.None
} else {
val top = scrollState.value.toFloat()
outOfViewEvents(
blocks = blocks,
topMin = top / hourPx * 60f,
bottomMin = (top + viewport) / hourPx * 60f,
)
}
}
/** Scrolls the event hidden past [edge] into view, if there is one. */
fun reveal(edge: ViewportEdge) {
val block = if (edge == ViewportEdge.Top) events.above else events.below
block ?: return
val target = revealScrollTarget(
block = block,
edge = edge,
hourPx = hourPx,
viewportPx = scrollState.viewportSize,
marginPx = marginPx,
maxScroll = scrollState.maxValue,
)
val spec = revealSpec
scope.launch {
if (reduceMotion || spec == null) {
scrollState.scrollTo(target)
} else {
scrollState.animateScrollTo(target, spec)
}
}
}
/** Reveals the hidden event if a tap at [y] (content px) lands on a bar. */
fun tap(y: Float): Boolean {
val edge = tappedEdge(
events = events,
y = y,
viewportTop = scrollState.value.toFloat(),
viewportPx = scrollState.viewportSize,
reachPx = reachPx,
) ?: return false
reveal(edge)
return true
}
}
@Composable
fun rememberOutOfViewEdges(
blocks: List<TimedBlock>,
scrollState: ScrollState,
hourPx: Float,
): OutOfViewEdges {
val scope = rememberCoroutineScope()
val edges = remember(scrollState, scope) { OutOfViewEdges(scrollState, scope) }
val density = LocalDensity.current
val reduceMotion = rememberReduceMotion()
val revealSpec = rememberCalendarPageSpec()
SideEffect {
edges.blocks = blocks
edges.hourPx = hourPx
edges.fadeLeadPx = with(density) { EDGE_FADE_LEAD.toPx() }
edges.marginPx = with(density) { EDGE_TOUCH_HEIGHT.roundToPx().toFloat() }
edges.reachPx = with(density) { EDGE_TAP_REACH.toPx() }
edges.reduceMotion = reduceMotion
edges.revealSpec = revealSpec
}
return edges
}
/**
* Thin bars at the top and bottom of a day column's visible viewport, tinted in
* the colour of the nearest timed event hidden past that edge (#188).
*
* Lives inside the column's scrolling content and follows the viewport by
* offsetting against [scrollState] at placement rather than recomposing every
* frame. Opacity follows the gap to the hidden event and reaches zero before
* the event enters view. Taps reach it through [OutOfViewEdges.tap]; screen
* readers get a button.
*/
@Composable
fun BoxScope.OutOfViewEdgeBars(
edges: OutOfViewEdges,
scrollState: ScrollState,
dark: Boolean,
) {
val touchPx = with(LocalDensity.current) { EDGE_TOUCH_HEIGHT.roundToPx() }
val events = edges.events
EdgeBar(
block = events.above,
edge = ViewportEdge.Top,
dark = dark,
onReveal = edges::reveal,
scrollState = scrollState,
hourPx = edges.hourPx,
fadeLeadPx = edges.fadeLeadPx,
modifier = Modifier
.align(Alignment.TopStart)
.offset { IntOffset(0, scrollState.value) },
)
EdgeBar(
block = events.below,
edge = ViewportEdge.Bottom,
dark = dark,
onReveal = edges::reveal,
scrollState = scrollState,
hourPx = edges.hourPx,
fadeLeadPx = edges.fadeLeadPx,
modifier = Modifier
.align(Alignment.TopStart)
.offset { IntOffset(0, scrollState.value + scrollState.viewportSize - touchPx) },
)
}
@Composable
private fun EdgeBar(
block: TimedBlock?,
edge: ViewportEdge,
dark: Boolean,
onReveal: (ViewportEdge) -> Unit,
scrollState: ScrollState,
hourPx: Float,
fadeLeadPx: Float,
modifier: Modifier = Modifier,
) {
block ?: return
val color = eventFill(block.event.color, dark, LocalSoftenColors.current)
val label = stringResource(
if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events,
)
Box(
modifier = modifier
.fillMaxWidth()
.height(EDGE_TOUCH_HEIGHT)
.graphicsLayer {
val gapPx = when (edge) {
ViewportEdge.Top -> scrollState.value - renderedEndMin(block) / 60f * hourPx
ViewportEdge.Bottom -> block.startMin / 60f * hourPx -
(scrollState.value + scrollState.viewportSize)
}
alpha = edgeBarAlpha(gapPx, fadeLeadPx)
}
.semantics {
role = Role.Button
contentDescription = label
onClick {
onReveal(edge)
true
}
},
contentAlignment = if (edge == ViewportEdge.Top) Alignment.TopCenter else Alignment.BottomCenter,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = EDGE_BAR_HORIZONTAL_INSET,
vertical = EDGE_BAR_VERTICAL_INSET,
)
.height(EDGE_BAR_THICKNESS)
.background(color, CircleShape),
)
}
}
@@ -0,0 +1,53 @@
package de.jeanlucmakiola.calendula.ui.common
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
/**
* 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
* across a swipe.
*
* Every entry shares on its own job, cancelled when [keep] lets it go: a
* `stateIn` on the ViewModel's scope would stay running, holding its last state,
* until the ViewModel itself was cleared. Main thread only.
*
* @param keep whether a cached key is still worth holding once [get] asks for another.
*/
class PageStateCache<K, S>(
private val scope: CoroutineScope,
private val initial: S,
private val keep: (cached: K, requested: K) -> Boolean,
private val load: (K) -> Flow<S>,
) {
private class Entry<S>(val state: StateFlow<S>, val job: Job)
private val entries = HashMap<K, Entry<S>>()
/** The number of entries held, for tests. */
internal val size: Int get() = entries.size
fun get(key: K): StateFlow<S> {
val iterator = entries.iterator()
while (iterator.hasNext()) {
val (cached, entry) = iterator.next()
if (cached != key && !keep(cached, key)) {
entry.job.cancel()
iterator.remove()
}
}
return entries.getOrPut(key) {
val job = SupervisorJob(scope.coroutineContext[Job])
Entry(
state = load(key).stateIn(scope + job, SharingStarted.WhileSubscribed(5_000L), initial),
job = job,
)
}.state
}
}
@@ -12,7 +12,7 @@ import kotlin.time.Instant
* drawn dimmed in the month/week grids — i.e. the current wall-clock minute when
* the "dim completed events" setting is on, or `null` when it is off (nothing
* dims). Provided per grid screen so only the event chips that read it recompose
* as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat].
* as the minute ticks, mirroring [LocalShowHourGrid] / [LocalUse24HourFormat].
*/
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
@@ -24,8 +25,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
@@ -34,7 +36,6 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
@@ -83,6 +84,13 @@ data class TimelineDrag(
val eventSpanMin: Int,
/** The event as the grid would draw it, one piece per day column it covers. */
val pieces: List<TimelineDragPiece>,
/**
* Lines the block the finger picked up drew its title over, zero for one too
* short to have drawn it at all. The floating copy is that block's size, so
* it has to spend its height the same way or the text re-wraps under the
* finger and snaps back on drop (#267).
*/
val titleLines: Int,
/**
* True when [pieces] is only [edgeDragSlice]'s stand-in — the event has left
* every column this timeline shows. A hint under the finger, not where the
@@ -192,6 +200,32 @@ internal fun dragFloorMin(clipOffsetMin: Int, eventSpanMin: Int): Int =
0
}
/**
* Where one dragged piece's top edge sits below its column's top, and how tall
* it is drawn, at [hourPx] to the hour. [insetPx] is the half-gap a block seated
* in the hour grid gives up at each end, zero with the grid off — taken off the
* copy exactly as the grid takes it off the block, or the copy is a gap taller
* than the block it lifted off and spends the difference on a time label that
* block had no room for (#267). An end cut at midnight keeps none of it, as the
* block's own squared-off edge does.
*/
internal fun dragPieceBounds(
slice: DragSlice,
hourPx: Float,
insetPx: Float,
): DragPieceBounds {
val top = if (slice.continuesBefore) 0f else insetPx
val bottom = if (slice.continuesAfter) 0f else insetPx
val full = maxOf(slice.spanMin / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
return DragPieceBounds(
top = slice.startMin / 60f * hourPx + top,
height = (full - top - bottom).coerceAtLeast(0f),
)
}
/** A piece's placement within its column — see [dragPieceBounds]. */
internal data class DragPieceBounds(val top: Float, val height: Float)
/** One day's share of a dragged event, before it is placed on screen. */
internal data class DragSlice(
val dayOffset: Int,
@@ -235,6 +269,13 @@ class TimelineGeometry {
var stepPx: Float = 0f
var days: List<LocalDate> = emptyList()
/**
* The half-gap a block gives up at each uncut end when the hour grid seats
* it in a cell, zero with the grid off. The floating copy gives up the same,
* so it is the size of the block it lifted off (#267).
*/
var blockInsetPx: Float = 0f
/**
* Whether the columns are laid out right-to-left. Pointer coordinates are
* never mirrored but the grid is, so the mapping has to flip with it.
@@ -243,8 +284,8 @@ class TimelineGeometry {
}
/**
* Hoisted drag state for one timeline (#68). It lives above the per-page
* `AnimatedContent`, so a page change mid-drag can't strand a ghost, and the
* Hoisted drag state for one timeline (#68). It lives above the pager, so a
* page change mid-drag can't strand a ghost, and the
* block it renders is drawn in an overlay, clear of the day column's clip.
*/
@Stable
@@ -313,6 +354,9 @@ class TimelineDragController {
*/
private var clipOffsetMin = 0
/** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */
private var titleLines = 0
/**
* The slot the block already occupied when it was picked up — not the same
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
@@ -322,11 +366,13 @@ class TimelineDragController {
fun begin(
block: TimedBlock,
clipOffsetMin: Int,
titleLines: Int,
pointerInRoot: Offset,
blockInRoot: Offset,
) {
source = block
this.clipOffsetMin = clipOffsetMin
this.titleLines = titleLines
settling = null
isDragging = true
liftedInstanceId = block.event.instanceId
@@ -345,6 +391,7 @@ class TimelineDragController {
fun cancel() {
source = null
clipOffsetMin = 0
titleLines = 0
isDragging = false
liftedInstanceId = null
originSlot = null
@@ -467,23 +514,22 @@ class TimelineDragController {
startMin = startMin,
eventStartMin = eventStartMin,
eventSpanMin = eventSpan,
titleLines = titleLines,
// Bounded to the columns this timeline actually shows: a day it
// doesn't has no piece to draw. The write is unaffected.
pieces = slices
.map { slice ->
val day = dayIndex + slice.dayOffset
val col = if (geometry.isRtl) days.lastIndex - day else day
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
TimelineDragPiece(
topLeftInRoot = Offset(
x = origin.x + col * columnPx,
y = origin.y + slice.startMin / 60f * hourPx,
y = origin.y + bounds.top,
),
sizePx = IntSize(
(columnPx - geometry.columnGapPx).roundToInt(),
maxOf(
slice.spanMin / 60f * hourPx,
MIN_EVENT_FRACTION * hourPx,
).roundToInt(),
bounds.height.roundToInt(),
),
continuesBefore = slice.continuesBefore,
continuesAfter = slice.continuesAfter,
@@ -604,8 +650,8 @@ const val SETTLE_FADE_MILLIS: Int = 250
@Composable
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
var origin by remember { mutableStateOf(Offset.Zero) }
val density = LocalDensity.current
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val reduceMotion = rememberReduceMotion()
@@ -655,7 +701,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
animationSpec = tween(SETTLE_FADE_MILLIS),
label = "drag-handover",
)
val fill = eventFill(drag.event.color, dark, soften)
val paint = eventPaint(drag.event, dark)
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
// Read off the event rather than the held block, so grabbing either half
// of an event that crosses midnight names the same hours. An end past
@@ -666,22 +712,61 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd
val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}–" +
formatMinuteOfDay(endMin, use24Hour, locale)
// The tallest piece carries the range: on the smallest it would be
// clipped away, which is exactly the case when a short tail is held.
val labelled = drag.pieces.indices.maxByOrNull { drag.pieces[it].sizePx.height }
drag.pieces.forEachIndexed { index, piece ->
DragCopy(
topLeftInRoot = piece.topLeftInRoot,
overlayOrigin = origin,
sizePx = piece.sizePx,
fill = fill,
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
lift = lift,
alpha = copyAlpha,
title = title,
// Once for the whole event: repeated, it would name it per day.
label = label.takeIf { index == labelled },
)
// The timeline's own bounds. The copy is drawn over the whole calendar
// 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 viewport, and unclipped it paints over the headers above (#267).
val port = controller.geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
// 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
// than the screen, its top at a midnight scrolled out of sight. Picking
// the range's piece by raw height put it there, off the top of the
// timeline; picking by *visible* height puts it where it can be read.
val shown = drag.pieces.map { piece ->
if (port == null) {
piece.sizePx.height.toFloat()
} else {
val top = maxOf(piece.topLeftInRoot.y, port.top)
val bottom = minOf(piece.topLeftInRoot.y + piece.sizePx.height, port.bottom)
bottom - top
}
}
val labelled = shown.indices.maxByOrNull { shown[it] }
Box(
modifier = if (port == null) {
Modifier
} else {
Modifier
.absoluteOffset {
IntOffset(
(port.left - origin.x).roundToInt(),
(port.top - origin.y).roundToInt(),
)
}
.size(
width = with(density) { port.width.toDp() },
height = with(density) { port.height.toDp() },
)
.clipToBounds()
},
) {
val pieceOrigin = port?.topLeft ?: origin
drag.pieces.forEachIndexed { index, piece ->
DragCopy(
topLeftInRoot = piece.topLeftInRoot,
overlayOrigin = pieceOrigin,
sizePx = piece.sizePx,
paint = paint,
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
cuts = timedBlockCuts(piece.continuesBefore, piece.continuesAfter),
lift = lift,
alpha = copyAlpha,
title = title,
titleLines = drag.titleLines,
// Once for the whole event: repeated, it would name it per day.
label = label.takeIf { index == labelled },
)
}
}
}
}
@@ -692,14 +777,49 @@ private fun DragCopy(
topLeftInRoot: Offset,
overlayOrigin: Offset,
sizePx: IntSize,
fill: Color,
paint: EventPaint,
shape: RoundedCornerShape,
cuts: ChipCuts,
lift: Float,
alpha: Float,
title: String,
titleLines: Int,
label: String?,
) {
val density = LocalDensity.current
val width = with(density) { sizePx.width.toDp() }
val height = with(density) { sizePx.height.toDp() }
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
// The block's size, spent the block's way — text sits at the top as it does
// on the block, so the copy hands back to the grid without shifting (#267),
// and it squeezes its inset on the same terms so a short block's title does
// not vanish the moment it is lifted (#289).
// The title is served in full first and the range lives off what is left:
// the hour gutter down the side still says where the copy sits, so the
// range is the half that can afford to go.
val metrics = rememberBlockTextMetrics(height)
val allowed = titleLines.coerceAtMost(metrics.titleBudget(metrics.available))
// Re-measured at the copy's own width rather than spent on the source's
// count: a block sharing its column with another is a lane wide where the
// copy is a whole column, so the source's second line is one the copy never
// draws — and reserving it took the range away with it (#267).
val lines = if (allowed <= 0) {
0
} else {
blockTextLines(
text = title,
style = MaterialTheme.typography.labelMedium.withTitleWeight(paint),
textWidth = textWidth,
max = allowed,
)
}
val left = metrics.available - metrics.titleHeight(lines)
val showTime = label != null && left >= metrics.timeLine
val timeMaxLines = if (showTime) {
blockTimeLines(label!!, textWidth, left - metrics.timeLine)
} else {
1
}
Box(
modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware
@@ -710,12 +830,20 @@ private fun DragCopy(
(topLeftInRoot.y - overlayOrigin.y).roundToInt(),
)
}
.size(
width = with(density) { sizePx.width.toDp() },
height = with(density) { sizePx.height.toDp() },
)
.padding(horizontal = 1.dp)
// Required: the parent is the viewport's size, and a plain `size`
// would let it cap a piece a whole day tall at one screen — drawn
// from a midnight scrolled off the top, that ended the column a
// scroll offset short of its own bottom (#267). The parent clips it.
.requiredSize(width = width, height = height)
.padding(horizontal = BLOCK_OUTER_INSET)
.graphicsLayer {
// Anchored at the top edge, not the middle: scaled about the
// centre, the 2% lift raises the top by 1% of the height — a few
// pixels on an hour, but a steady upward creep of the text as a
// dragged multi-day piece grows, and a shear against the
// timeline's clip. It snapped back when the lift animated out
// on drop (#267).
transformOrigin = TransformOrigin(0.5f, 0f)
scaleX = 1f + 0.02f * lift
scaleY = 1f + 0.02f * lift
shadowElevation = 8.dp.toPx() * lift
@@ -723,24 +851,29 @@ private fun DragCopy(
this.shape = shape
clip = false
}
.background(fill, shape)
.padding(horizontal = 4.dp, vertical = 2.dp),
.eventSurface(paint, shape, cuts)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset),
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f),
)
if (label != null) {
if (lines > 0) {
BlockTitle(
title = title,
maxLines = lines,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
)
}
if (showTime) {
val overflow = eventTitleOverflow(singleLine = timeMaxLines == 1)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
// As the block it lifted off sets its own time (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
maxLines = timeMaxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
color = paint.secondaryInk,
)
}
}
@@ -0,0 +1,58 @@
package de.jeanlucmakiola.calendula.ui.common
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* The hours the week and day timelines fit to the screen when they open (#322).
*
* Nothing outside the range is hidden: [TimelineScale.FitHours] only sizes the
* hour so that [startHour]..[endHour] fills the viewport, and the timeline opens
* scrolled to [startHour]. The hours are kept when [enabled] is switched off, so
* switching it back on restores the chosen range.
*/
data class TimelineHours(
val enabled: Boolean = false,
val startHour: Int = DEFAULT_START_HOUR,
val endHour: Int = DEFAULT_END_HOUR,
) {
val span: Int get() = endHour - startHour
companion object {
const val DEFAULT_START_HOUR = 7
const val DEFAULT_END_HOUR = 22
/** Shortest range that can be chosen, in hours. */
const val MIN_SPAN = 2
/**
* A range with [startHour] and [endHour] held to the day and at least
* [MIN_SPAN] apart; the start gives way when the two collide.
*/
fun of(enabled: Boolean, startHour: Int, endHour: Int): TimelineHours {
val end = endHour.coerceIn(MIN_SPAN, 24)
val start = startHour.coerceIn(0, end - MIN_SPAN)
return TimelineHours(enabled, start, end)
}
/** Start hours that still leave [MIN_SPAN] before [endHour]. */
fun startOptions(endHour: Int): List<Int> = (0..endHour - MIN_SPAN).toList()
/** End hours at least [MIN_SPAN] after [startHour]; 24 is midnight. */
fun endOptions(startHour: Int): List<Int> = (startHour + MIN_SPAN..24).toList()
}
}
/**
* 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
* 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
* the wrong hour.
*/
internal fun startHourScroll(maxValue: Int, viewportPx: Int, startHour: Int, hourPx: Float): Int? {
if (viewportPx <= 0 || maxValue < 0 || maxValue == Int.MAX_VALUE) return null
val laidOutHourPx = (maxValue + viewportPx) / 24f
if (abs(laidOutHourPx - hourPx) > 1f) return null
return (startHour * laidOutHourPx).roundToInt().coerceIn(0, maxValue)
}
@@ -38,6 +38,12 @@ sealed interface TimelineScale {
/** A height the user pinched to. Build it through [custom], which clamps. */
data class Custom(val hourHeight: Dp) : TimelineScale
/**
* [startHour]..[endHour] fills the viewport (#322). Applied when a timeline
* opens with [TimelineHours] on, never picked from the preset list.
*/
data class FitHours(val startHour: Int, val endHour: Int) : TimelineScale
companion object {
/** The named steps the settings picker offers, coarse to roomy. */
val presets: List<TimelineScale> = listOf(FitDay, Compact, Regular, Comfortable)
@@ -74,6 +80,9 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
is TimelineScale.Custom -> this.hourHeight
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
.coerceAtLeast(fillHourHeight(viewportHeight))
is TimelineScale.FitHours -> (viewportHeight / (endHour - startHour).coerceIn(1, 24).toFloat())
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
.coerceAtLeast(fillHourHeight(viewportHeight))
}
/**
@@ -96,17 +105,6 @@ fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
*/
const val MIN_EVENT_FRACTION = 26f / 60f
/**
* Narrowest an event block may be and still wrap its title over several lines.
*
* Wrapping is driven by the block's height, so a tall block on a lane-split
* column would otherwise stack two or three characters per line — "Da/ily",
* "Fa/rmer/s…" — which reads worse than one ellipsised line. A full week column
* clears this on any phone; a split one never does, while the day view's much
* wider columns keep wrapping even several lanes deep.
*/
val MIN_TITLE_WRAP_WIDTH = 36.dp
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */
val FIT_DAY_MIN = 24.dp
@@ -145,6 +143,7 @@ val TimelineScale.labelRes: Int
TimelineScale.Regular -> R.string.timeline_scale_regular
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable
is TimelineScale.Custom -> R.string.timeline_scale_custom
is TimelineScale.FitHours -> R.string.settings_visible_hours
}
@get:StringRes
@@ -155,11 +154,15 @@ val TimelineScale.descriptionRes: Int
TimelineScale.Regular -> R.string.timeline_scale_regular_summary
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary
is TimelineScale.Custom -> R.string.timeline_scale_custom_summary
is TimelineScale.FitHours -> R.string.settings_visible_hours_summary
}
/** Marks a stored custom height; the rest of the value is its dp. */
private const val CUSTOM_PREFIX = "custom:"
/** Marks a stored hour range, as `start-end`. */
private const val HOURS_PREFIX = "hours:"
/**
* Stored names for the presets. Spelled out rather than taken from `toString()`
* so R8 can't rename them out from under an existing install — and they match
@@ -182,6 +185,7 @@ private val PRESET_NAMES: Map<TimelineScale, String> = mapOf(
*/
fun TimelineScale.storageValue(): String = when (this) {
is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value
is TimelineScale.FitHours -> "$HOURS_PREFIX$startHour-$endHour"
TimelineScale.FitDay,
TimelineScale.Compact,
TimelineScale.Regular,
@@ -199,5 +203,11 @@ fun parseTimelineScale(stored: String?): TimelineScale = when {
stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull()
?.let { TimelineScale.custom(it.dp) }
?: TimelineScale.Regular
stored.startsWith(HOURS_PREFIX) -> stored.removePrefix(HOURS_PREFIX).split('-')
.mapNotNull { it.toIntOrNull() }
.takeIf { it.size == 2 }
?.let { (start, end) -> TimelineHours.of(enabled = true, start, end) }
?.let { TimelineScale.FitHours(it.startHour, it.endHour) }
?: TimelineScale.Regular
else -> PRESET_NAMES.entries.firstOrNull { it.value == stored }?.key ?: TimelineScale.Regular
}
@@ -0,0 +1,101 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlin.math.roundToInt
/**
* Where 00:00 sat in the last timeline shown, measured from the top of its
* view's content, so the next timeline view opens on the same hours (#184): an
* event in both keeps its place and only grows sideways.
*/
class TimelineScrollMemory {
var midnightY: Float? = null
}
val LocalTimelineScroll = staticCompositionLocalOf<TimelineScrollMemory?> { null }
/** A timeline's scroll, and where it opens before it can scroll there. */
@Stable
class TimelineScroll(val state: ScrollState, noonPending: Boolean, private val openingHour: Int?) {
/** Whether it is still to be put on noon, which it only can be once laid out. */
var noonPending by mutableStateOf(noonPending)
internal set
/** The top of what it shows, or will once open, over [content] pixels in a [viewport] pixels high. */
fun top(viewport: Float, content: Float): Float =
if (noonPending) {
(openingHour?.let { it * content / 24f } ?: (content - viewport) / 2f)
.coerceIn(0f, (content - viewport).coerceAtLeast(0f))
} else state.value.toFloat()
}
val LocalOpeningTimeline = staticCompositionLocalOf<TimelineScroll?> { null }
/**
* The scroll state of a timeline whose scrolling area starts [timelineTop] below
* its view's content top. Opens where the last timeline left off, or on noon the
* first time.
*/
@Composable
fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
val memory = LocalTimelineScroll.current
val density = LocalDensity.current
val zoom = LocalTimelineZoom.current
val topPx = with(density) { timelineTop.toPx() }
val remembered = memory?.midnightY
val scrollState = rememberScrollState(
initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0,
)
val scroll = remember(scrollState) {
TimelineScroll(
scrollState,
noonPending = remembered == null,
openingHour = zoom.hours.takeIf { it.enabled }?.startHour,
)
}
val currentTop by rememberUpdatedState(topPx)
var firstOpen by remember(scrollState) { mutableStateOf(true) }
LaunchedEffect(scrollState, zoom, zoom.hours) {
if (firstOpen && remembered != null) {
firstOpen = false
return@LaunchedEffect
}
firstOpen = false
val startHour = zoom.open()
if (startHour == null) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
scrollState.scrollTo(scrollState.maxValue / 2)
} else {
val (target, hourHeight) = snapshotFlow {
val viewport = scrollState.viewportSize
val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() })
val hourPx = with(density) { hourHeight.toPx() }
startHourScroll(scrollState.maxValue, viewport, startHour, hourPx)?.let { it to hourHeight }
}.filterNotNull().first()
scrollState.scrollTo(target)
zoom.holdFit(hourHeight)
}
scroll.noonPending = false
}
if (memory != null) {
LaunchedEffect(scrollState, memory) {
snapshotFlow { currentTop - scrollState.value }.collect { memory.midnightY = it }
}
}
return scroll
}
@@ -19,7 +19,6 @@ import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt
@@ -50,13 +49,49 @@ class TimelineZoom(
var isPinching: Boolean by mutableStateOf(false)
private set
/** The visible-hours preference (#322); a timeline re-fits when it changes. */
var hours: TimelineHours by mutableStateOf(TimelineHours())
private set
private var stored: TimelineScale = initial
/**
* Take a value that came from the preference. Ignored mid-pinch: the stored
* value is a frame or two behind the fingers there, and letting it land would
* snap the timeline back while the user is still pinching.
* snap the timeline back while the user is still pinching. With visible
* hours on it is only remembered, so a late-arriving preference can't undo
* the fit a timeline just opened with.
*/
fun adopt(stored: TimelineScale) {
if (!isPinching) scale = stored
this.stored = stored
if (!isPinching && !hours.enabled) scale = stored
}
/** Take the visible-hours preference; switching it off hands back the stored scale. */
fun adoptHours(hours: TimelineHours) {
val wasEnabled = this.hours.enabled
this.hours = hours
if (wasEnabled && !hours.enabled && !isPinching) scale = stored
}
/**
* A timeline is opening. With visible hours on, the range replaces whatever
* scale is in force — a stored preset and a pinch alike — and its start hour
* is returned for the timeline to scroll to; otherwise null.
*/
fun open(): Int? {
val hours = hours
if (!hours.enabled) return null
if (!isPinching) scale = TimelineScale.FitHours(hours.startHour, hours.endHour)
return hours.startHour
}
/**
* Holds the height [open] fitted, so a viewport that changes afterwards (the
* all-day strip following a swipe) doesn't rescale the hours under the scroll.
*/
fun holdFit(hourHeight: Dp) {
if (!isPinching && scale is TimelineScale.FitHours) scale = TimelineScale.Custom(hourHeight)
}
fun beginPinch() {
@@ -67,9 +102,10 @@ class TimelineZoom(
scale = TimelineScale.custom(hourHeight)
}
/** Settles the pinch. With visible hours on it only lasts until the next [open]. */
fun endPinch() {
isPinching = false
persist(scale)
if (!hours.enabled) persist(scale)
}
}
@@ -83,16 +119,18 @@ private val DetachedTimelineZoom by lazy { TimelineZoom(TimelineScale.Regular) {
val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom }
/**
* The app-wide [TimelineZoom], seeded from [stored] and writing settled pinches
* back through [onPersist].
* The app-wide [TimelineZoom], seeded from [stored] and [hours] and writing
* settled pinches back through [onPersist].
*/
@Composable
fun rememberTimelineZoom(
stored: TimelineScale,
hours: TimelineHours,
onPersist: (TimelineScale) -> Unit,
): TimelineZoom {
val persist by rememberUpdatedState(onPersist)
val zoom = remember { TimelineZoom(stored) { persist(it) } }
LaunchedEffect(hours) { zoom.adoptHours(hours) }
// Picking a preset in Settings has to reach the timelines, and so does the
// stored value arriving after the first frame.
LaunchedEffect(stored) { zoom.adopt(stored) }
@@ -197,18 +235,26 @@ fun rememberTimelinePinchZoom(
* names, jumping the whole column as a pinch drifts across each half pixel.
* Pinning the hour to whole pixels keeps every part of the timeline on one grid.
*
* The bounds themselves are pulled onto that grid too, each in the direction
* that keeps its own promise — up for the fill floor, so no dead space opens
* under midnight, down for the ceiling. A fractional bound would be a height the
* pinch can be held against but never actually land on, and the difference feeds
* the focal anchor a scroll correction on every frame the fingers sit still.
* The ceiling is pulled onto that grid too, downwards, so it stays a height the
* pinch can actually land on.
*
* [fillPx] is deliberately *not* rounded (#290). It is the one height the whole
* day exactly fills the viewport at, and it is the same value
* [TimelineScale.FitDay] resolves to — rounding it up by the fraction of a pixel
* that 24 hours don't divide the viewport into leaves the timeline a pixel per
* hour taller than its own viewport, so a pinched-all-the-way-out day still
* scrolls a hair and bounces off Android's overscroll stretch, while the
* identical FitDay preset sits still. Being the clamp result rather than a bound
* the gesture is merely held against, it is a height the pinch does land on: the
* next frame reads it back unchanged and the focal anchor is handed nothing to
* correct.
*/
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
// Filling the viewport wins over the ceiling: on a screen tall enough for
// the two to disagree, dead space is the worse of the two failures.
target.roundToInt().toFloat()
.coerceAtMost(floor(maxPx))
.coerceAtLeast(ceil(fillPx))
.coerceAtLeast(fillPx)
/**
* The scroll offset that keeps the moment under [centroidY] under it after the
@@ -1,26 +1,82 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.Icon
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
private val GlyphShape = RoundedCornerShape(7.dp)
private val GlyphBorder = 1.5.dp
/**
* Height of the day number. Expressed in dp and converted to sp at draw time so
* it does not follow the font scale: this is an icon glyph sharing the bar with
* stroke icons, and a two-digit day that grew with the text would push the bar
* past the width #165 measured it to. The button's content description carries
* the meaning for screen readers, so nothing is lost by holding it still.
*/
private val GlyphTextHeight = 12.dp
/**
* The top-bar "jump to today" icon button, shared by every calendar view's app
* bar (#60). Shown in place of the fade-in FAB pill when the user moves the
* today control into the toolbar; renders nothing when [show] is false, so it
* drops straight into an app bar's `actions` slot without a wrapping condition.
*
* The glyph is the current day number in an outlined box rather than a generic
* calendar icon (#220), so the bar also says what day it is. Outlined rather
* than the month grid's filled circle, which reads as "this cell is today" and
* would sit heavier here than the stroke icons beside it.
*/
@Composable
fun TodayAction(show: Boolean, onToday: () -> Unit) {
if (!show) return
IconButton(onClick = onToday) {
Icon(
imageVector = Icons.Default.Today,
contentDescription = stringResource(R.string.today_jump_action),
)
val now = rememberCurrentMinute()
// Only the day number is read, so the bar recomposes at midnight rather
// than on every minute tick.
val day by remember {
derivedStateOf { now.value.toLocalDateTime(TimeZone.currentSystemDefault()).date.day }
}
val description = stringResource(R.string.today_jump_action)
IconButton(
onClick = onToday,
modifier = Modifier
.semantics { contentDescription = description },
) {
val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() }
Box(
modifier = Modifier
.size(AppBarSpacing.IconSize)
.border(GlyphBorder, LocalContentColor.current, GlyphShape),
contentAlignment = Alignment.Center,
) {
Text(
text = day.toString(),
fontSize = textSize,
lineHeight = textSize,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = LocalContentColor.current,
)
}
}
}
@@ -0,0 +1,49 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.datetime.LocalDate
/**
* The date every calendar view is looking at (#184), so switching views keeps
* the same days on screen and what is shown in both can travel between them.
*
* A view reads it once, as it comes on screen, and writes it back only when the
* user moves: a view settling where it opened must not nudge it, or switching
* back and forth would drift.
*/
@Stable
class ViewFocus(initial: LocalDate) {
var date by mutableStateOf(initial)
/** Explicit navigation, including a request for the view already on screen. */
var navigationVersion by mutableStateOf(0)
private set
fun navigateTo(target: LocalDate) {
date = target
navigationVersion++
}
}
val ViewFocusSaver: Saver<ViewFocus, String> = Saver(
save = { it.date.toString() },
restore = { ViewFocus(LocalDate.parse(it)) },
)
val LocalViewFocus = staticCompositionLocalOf<ViewFocus?> { null }
/**
* Runs [enter] with the focused date once, as the view comes on screen, before
* it reads its own position: its first frame is then already on that date.
*/
@Composable
fun <T : Any> EnterOnFocus(key: T, enter: (LocalDate) -> Unit) {
val focus = LocalViewFocus.current
remember(key) { focus?.date?.also(enter) }
}
@@ -0,0 +1,166 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.BoundsTransform
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
/**
* What the views share when switching between them (#184): an event visible in
* both grows from where it sat into where it lands, a date label travels to its
* new seat, and everything else fades through.
*
* An element is tagged with the day it is drawn *from*, not just its instance: a
* multi-day event is drawn once per day, per week row or per timeline column,
* and only one element per key may take part. The segment that starts on the
* same day in both views is the one that travels; the rest fade where they are.
*/
sealed interface ViewMorphKey {
data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey
data class Day(val date: LocalDate) : ViewMorphKey
companion object {
/** [event]'s occurrence as drawn from [day]. */
fun event(event: EventInstance, day: LocalDate): Event =
Event(event.eventId, event.start.toEpochMilliseconds(), day)
}
}
/** The view switch's scopes and specs, captured where they exist. Null under reduced motion. */
@OptIn(ExperimentalSharedTransitionApi::class)
class ViewMorphScope(
val shared: SharedTransitionScope,
val visibility: AnimatedVisibilityScope,
val boundsTransform: BoundsTransform,
val fadeSpec: FiniteAnimationSpec<Float>,
val clip: BelowTopBarClip,
val stayingPut: StayingPut,
)
/**
* The events a view has but keeps out of the switch, being scrolled out of
* sight: the other view leaves them untagged too, or its half would travel to
* nowhere and snap into place at the end.
*/
class StayingPut {
val keys = mutableStateSetOf<ViewMorphKey.Event>()
}
/** Marks [key] as staying put while [stays], for as long as it is composed. */
@Composable
fun StayPut(key: ViewMorphKey.Event, stays: Boolean) {
val stayingPut = LocalViewMorph.current?.stayingPut ?: return
if (!stays) return
DisposableEffect(stayingPut, key) {
stayingPut.keys += key
onDispose { stayingPut.keys -= key }
}
}
/**
* Keeps what travels below the top bar, which every view has at the same height:
* the overlay draws above the whole screen, so an event scrolled under the bar
* would otherwise cross it. [top] is the bar's bottom edge in the shared layout.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
class BelowTopBarClip : SharedTransitionScope.OverlayClip {
var top = 0f
override fun getClipPath(
sharedContentState: SharedTransitionScope.SharedContentState,
bounds: Rect,
layoutDirection: LayoutDirection,
density: Density,
): Path = Path().apply { addRect(Rect(-FAR, top, FAR, FAR)) }
private companion object {
const val FAR = 1e6f
}
}
val LocalViewMorph = compositionLocalOf<ViewMorphScope?> { null }
/**
* Whether a pager page may carry the morph tags: only the settled one. Its
* neighbours are composed ahead and repeat some of its dates, which would both
* claim its keys and fly in from off screen.
*/
fun morphPageActive(page: Int, settledPage: Int): Boolean = page == settledPage
/** Provides the view morph to [content] only while [active] — see [morphPageActive]. */
@Composable
fun ViewMorphPage(active: Boolean, content: @Composable () -> Unit) {
val morph = LocalViewMorph.current
CompositionLocalProvider(LocalViewMorph provides morph.takeIf { active }, content = content)
}
/**
* Tags an event chip, bar, block or row. Its bounds are shared and the two faces
* cross-fade inside them; remeasured rather than scaled, so a title keeps its
* real size and is clipped while there is no room, instead of smearing.
*
* Goes after the element's offset and before its size, so the travelling bounds
* drive the measurement.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier {
val morph = LocalViewMorph.current ?: return this
if (key in morph.stayingPut.keys) return this
return with(morph.shared) {
this@morphEvent.sharedBounds(
sharedContentState = rememberSharedContentState(key),
animatedVisibilityScope = morph.visibility,
enter = fadeIn(morph.fadeSpec),
exit = fadeOut(morph.fadeSpec),
boundsTransform = morph.boundsTransform,
clipInOverlayDuringTransition = morph.clip,
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
zIndexInOverlay = 1f,
)
}
}
/** Tags a day's number, which is small on both sides, so it scales on its way. */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Modifier.morphDay(date: LocalDate): Modifier {
val morph = LocalViewMorph.current ?: return this
return with(morph.shared) {
this@morphDay.sharedBounds(
sharedContentState = rememberSharedContentState(ViewMorphKey.Day(date)),
animatedVisibilityScope = morph.visibility,
enter = fadeIn(morph.fadeSpec),
exit = fadeOut(morph.fadeSpec),
boundsTransform = morph.boundsTransform,
clipInOverlayDuringTransition = morph.clip,
zIndexInOverlay = 1f,
)
}
}
/** The view switch's travel, on the same spring as the timeline's columns resizing. */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun rememberViewMorphBoundsTransform(): BoundsTransform {
val spec = rememberViewSwitchSpec<Rect>()
return remember(spec) { BoundsTransform { _, _ -> spec } }
}
@@ -1,27 +1,57 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
/**
* Top-bar pill that shows the current view and cycles to the next one on tap
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
*
* Renders nothing when [cycle] holds fewer than [QuickSwitchConfig.MIN_CYCLE]
* views (#150), so it drops straight into an app bar's `actions` slot without a
* wrapping condition — the same way [TodayAction] handles being turned off.
*
* [trailingInset] lands the pill's background on the bar's own rhythm; pass
* `0.dp` outside a top app bar, where M3's actions padding is not there to
* cancel (#165).
*/
@Composable
fun ViewSwitcherPill(
current: CalendarView,
cycle: List<CalendarView>,
onCycle: () -> Unit,
modifier: Modifier = Modifier,
trailingInset: Dp = AppBarSpacing.ContainerTrailingInset,
) {
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
val fadeSpec = rememberCalendarFadeSpec()
FilledTonalButton(
onClick = onCycle,
shape = MaterialTheme.shapes.large,
modifier = modifier,
modifier = modifier
.padding(end = trailingInset),
) {
Text(stringResource(current.labelRes))
// The label cross-fades and the pill eases to its width, so cycling
// views turns it over rather than snapping it (#184).
AnimatedContent(
targetState = current.label(),
transitionSpec = {
(fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false))
},
contentAlignment = Alignment.Center,
) { label ->
Text(label)
}
}
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.calendula.ui.day
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
/** Pages the day pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
internal const val DAY_PAGE_COUNT: Int = 140_000
private const val EPOCH_PAGE = DAY_PAGE_COUNT / 2
/** The day at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
private val PAGE_EPOCH = LocalDate(2000, 1, 1)
/** The day shown on pager [page]. */
internal fun dayForPage(page: Int): LocalDate =
PAGE_EPOCH.plus(page - EPOCH_PAGE, DateTimeUnit.DAY)
/** The pager page showing [date]. */
internal fun dayPageFor(date: LocalDate): Int =
(EPOCH_PAGE + PAGE_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
@@ -1,838 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
import de.jeanlucmakiola.calendula.ui.common.HourGutter
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import java.util.Locale
import kotlin.math.roundToInt
private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp
/** Total all-day strip height for the day (0 when there are no all-day events). */
internal fun DayUiState.Success.allDayStripHeight(): Dp {
if (allDay.isEmpty()) return 0.dp
val lanes = allDay.maxOf { it.lane } + 1
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DayScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier,
initialDateIso: String? = null,
viewModel: DayViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val date by viewModel.date.collectAsStateWithLifecycle()
// When opened from the month grid, anchor to the tapped date.
LaunchedEffect(initialDateIso) {
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
// The all-day strip shares the app bar's scrolled colour so the whole top
// region elevates together once the timeline scrolls under it.
val topSectionColor by animateColorAsState(
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
MaterialTheme.colorScheme.surfaceContainer
} else {
MaterialTheme.colorScheme.surface
},
label = "day-top-section-color",
)
val isOnToday = when (val s = state) {
is DayUiState.Success -> s.date == s.today
else -> true
}
// Drives whether the title carries the year. Falls back to the clock only
// while the first load is in flight, when there is no state to read today from.
val currentYear = when (val s = state) {
is DayUiState.Success -> s.today.year
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
}
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() }
val goPrev = { slideDir = -1; viewModel.goToPrev() }
// Slide toward today: viewing the future → today comes in from the left
// (back), viewing the past → from the right (forward).
val jumpToToday = {
slideDir = when (val s = state) {
is DayUiState.Success -> if (s.today < s.date) -1 else 1
else -> 0
}
viewModel.goToToday()
}
// Drawer jump-to-date: slide from the side the target lies on.
val jumpToDate: (LocalDate) -> Unit = { target ->
slideDir = if (target < date) -1 else 1
viewModel.goToDate(target)
}
ModalNavigationDrawer(
drawerState = drawerState,
// Open only via the menu button — edge-swipe would fight the day swipe.
gesturesEnabled = drawerState.isOpen,
drawerContent = {
CalendarDrawer(
currentView = selectedView,
currentDate = date,
drawerState = drawerState,
viewOrder = drawerViewOrder,
onSelectView = { view ->
onSelectView(view)
scope.launch { drawerState.close() }
},
onJumpToDate = { target ->
jumpToDate(target)
scope.launch { drawerState.close() }
},
onSettings = {
onOpenSettings()
scope.launch { drawerState.close() }
},
)
},
) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
DayTopBar(
date = date,
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = !isOnToday && !todayInToolbar,
todayText = stringResource(R.string.day_today_action),
onToday = jumpToToday,
onCreate = { onCreateEvent(date, null) },
)
},
) { innerPadding ->
DayContent(
state = state,
slideDir = slideDir,
topSectionColor = topSectionColor,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onEventClick = onEventClick,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
}
}
@Composable
private fun DayContent(
state: DayUiState,
slideDir: Int,
topSectionColor: Color,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
) {
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// Hoisted above the per-day AnimatedContent so the vertical scroll position
// survives day-to-day swipes. We only centre on noon once, on first entry
// into the day view (i.e. when arriving from the month/week view).
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
// Half the scroll range *is* noon: the content spans a full 24 hours, so
// centring the range centres midday at whatever hour height is in force.
scrollState.scrollTo(scrollState.maxValue / 2)
}
// Single, hoisted all-day strip height — shared by the outgoing and incoming
// day during a swipe, so the strip slides along but never jumps in height.
val targetAllDayHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp
val allDayHeight by animateDpAsState(
targetValue = targetAllDayHeight,
label = "day-all-day-strip-height",
)
// Whole-page horizontal swipe, one level above the timeline's vertical
// scroll: a horizontal drag crosses this detector's slop, while a vertical
// drag is consumed by the inner scroll first — the two gestures coexist.
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
// Above the AnimatedContent: a page change mid-drag would strand the
// floating block inside the outgoing page.
val dragController = rememberTimelineDragController()
val move = LocalEventMove.current
val zone = remember { TimeZone.currentSystemDefault() }
Box(modifier = modifier) {
AnimatedContent(
targetState = state,
modifier = Modifier.fillMaxSize().then(swipeModifier),
contentKey = { s ->
when (s) {
is DayUiState.Success -> "success-${s.date}"
is DayUiState.Failure -> "failure-${s.reason}"
DayUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "day-transition",
) { s ->
when (s) {
DayUiState.Loading -> DayLoading()
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is DayUiState.Success -> DaySuccess(
state = s,
topSectionColor = topSectionColor,
scrollState = scrollState,
allDayHeight = allDayHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = { drop ->
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Start(drop.startInstant(zone)),
),
)
},
)
}
}
TimelineDragOverlay(dragController)
}
}
@Composable
internal fun DaySuccess(
state: DayUiState.Success,
topSectionColor: Color,
scrollState: ScrollState,
allDayHeight: Dp,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
// All-day strip collapses to nothing when the day has no all-day events,
// so the timeline sits directly under the app bar.
AllDayStrip(
state = state,
height = allDayHeight,
onEventClick = onEventClick,
modifier = Modifier
.fillMaxWidth()
.background(topSectionColor),
)
// Breathing room between the (colour-shifting) top section and the
// scrolling timeline below.
Spacer(Modifier.height(8.dp))
Timeline(
state = state,
scrollState = scrollState,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DayTopBar(
date: LocalDate,
currentYear: Int,
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
val locale = currentLocale()
TopAppBar(
title = {
CalendarTitleButton(
title = formatDayTitle(date, locale, currentYear),
currentDate = date,
onJumpToDate = onJumpToDate,
)
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
),
scrollBehavior = scrollBehavior,
)
}
@Composable
private fun AllDayStrip(
state: DayUiState.Success,
height: Dp,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
val dark = isSystemInDarkTheme()
Row(
modifier = modifier
// Height is hoisted + animated so it resizes smoothly; padding sits
// inside it so the content area is lanes * row height.
.height(height)
.padding(
top = ALL_DAY_VERTICAL_PADDING,
bottom = ALL_DAY_VERTICAL_PADDING,
end = TIMELINE_CONTENT_END_INSET,
),
) {
// Keep the gutter-width offset so the bars line up with the day column.
Spacer(Modifier.width(GUTTER_WIDTH))
// Bars are positioned absolutely by lane (vertical stacking); each spans
// the full day-column width. clipToBounds keeps bars from spilling out
// while the height animates.
BoxWithConstraints(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clipToBounds(),
) {
val barWidth = maxWidth
state.allDay.forEach { span ->
AllDayBar(
event = span.event,
dark = dark,
onClick = { onEventClick(span.event) },
modifier = Modifier
.offset(y = ALL_DAY_ROW_HEIGHT * span.lane)
.width(barWidth)
.height(ALL_DAY_ROW_HEIGHT)
.padding(horizontal = 1.dp, vertical = 1.dp),
)
}
}
}
}
@Composable
private fun AllDayBar(
event: EventInstance,
dark: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
Box(
modifier = modifier
.background(fill, EventChipShape)
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
) {
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
)
}
}
@Composable
private fun Timeline(
state: DayUiState.Success,
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
val dark = isSystemInDarkTheme()
val zoom = LocalTimelineZoom.current
val density = LocalDensity.current
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
// timeline's own viewport height, which is only known here — below the top
// bar, date header and all-day strip.
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val hourHeight = zoom.scale.hourHeight(maxHeight)
val totalHeight = hourHeight * 24
// The pinch sits on the Row, above both scroll viewports: it has to
// outrank the vertical scroll, and it does that by watching the initial
// pass, which only reaches it if it is their ancestor.
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
// Gutter and day column are two scroll viewports that SHARE one scroll
// state, so they stay perfectly aligned. The day-column viewport is a
// static, rounded-clipped window — the content scrolls inside it, so the
// soft corners are permanent at any scroll position.
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
// Hour gutter (scrolls in sync with the day column). Start inset so the
// labels centre on the top bar hamburger, matching the week view.
HourGutter(
scrollState = scrollState,
hourHeight = hourHeight,
dragController = dragController,
)
// Day column: rounded, clipped scroll viewport (permanent corners).
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState)
.onGloballyPositioned { dragController.geometry.viewport = it },
) {
DayColumnCard(
blocks = state.timed,
dark = dark,
date = state.date,
today = state.today,
hourHeight = hourHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
modifier = Modifier
.fillMaxWidth()
.height(totalHeight)
// The scrolling content itself, so its root position
// already folds in the scroll offset.
.onGloballyPositioned { coords ->
dragController.geometry.let {
it.grid = coords
it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() }
it.columnGapPx = 0f
it.columnWidthPx = coords.size.width.toFloat()
it.days = listOf(state.date)
it.isRtl = isRtl
}
},
)
}
}
}
}
@Composable
private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
date: LocalDate,
today: LocalDate,
hourHeight: Dp,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
modifier: Modifier = Modifier,
) {
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
// Tells a settled drop when this column has caught up with it.
LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, blocks)
}
// The tap detector outlives the composition that installed it — a pinch or a
// Settings change moves the hour height without restarting it — so it reads
// the height and the callback through state handles instead of capturing
// them, or taps land on the scale the column had before the zoom (#148).
val currentHourPx = rememberUpdatedState(hourPx)
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
Card(
// Plain rectangular column — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
shape = RectangleShape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
modifier = modifier,
) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
// Faint hour separators sit over the column background but under
// the event blocks (drawBehind paints before the children).
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
// Tap an empty slot to create an event there. Taps on event
// blocks are consumed by their own click handler first, so this
// only fires on the column background. Snaps to the tapped hour.
.pointerInput(date) {
detectTapGestures { offset ->
currentOnCreateAt.value(
date,
tappedMinuteOfDay(offset.y, currentHourPx.value),
)
}
},
) {
val colWidth = maxWidth
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
// Keyed by event, so a block that changes time or lane is the *same*
// composable afterwards and tweens there. The ordinal disambiguates
// a column holding two occurrences of one series.
val ordinals = mutableMapOf<Long, Int>()
blocks.forEach { block ->
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
key(block.event.eventId, ordinal) {
val laneWidth = colWidth / block.laneCount
val top = hourHeight * (block.startMin / 60f)
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
val place = animatedBlockPlacement(
x = laneWidth * block.lane,
y = top,
width = laneWidth,
height = height,
)
EventBlock(
block = block,
dark = dark,
height = place.height,
date = date,
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
modifier = Modifier
.offset(x = place.x, y = place.y)
.width(place.width)
.height(place.height)
.padding(horizontal = 1.dp),
)
}
}
// Current-time line, on top of the events, only on today's column.
if (date == today) {
NowLine(date = date, hourHeight = hourHeight)
}
}
}
}
@Composable
private fun EventBlock(
block: TimedBlock,
dark: Boolean,
height: Dp,
date: LocalDate,
dragController: TimelineDragController,
onClick: () -> Unit,
onDrop: (TimelineDrop) -> Unit,
modifier: Modifier = Modifier,
) {
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
minToHm(block.endMin, use24Hour, locale)
val density = LocalDensity.current
val titleLineHeight = with(density) {
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// What's left for text once the 2.dp top/bottom padding is paid for. A block
// that cannot afford both lines spends its space on the title, and one too
// short even for that drops the title rather than serving a sliced one.
val available = height - 4.dp
val showTime = block.endMin - block.startMin >= 45 &&
available >= titleLineHeight + timeLineHeight
val showTitle = available >= titleLineHeight
val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event)
val draggable = eventDragAllowed(block.event)
// The drop takes this offset back off, so a tail clipped at midnight lands
// where the event's own start belongs (#253).
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
val shape = remember(block, date, zone) {
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
}
val dragModifier = rememberEventDragSource(
enabled = draggable,
key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ ->
dragController.begin(block, clipOffset, pointer, blockRoot)
},
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel,
)
val lifted = draggable && dragController.ghosts(block)
val ghost = ghostAlpha(lifted)
Box(
modifier = modifier
// The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, shape)
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = 4.dp, vertical = 2.dp)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
},
) {
Column {
if (showTitle) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined),
)
}
if (showTime) {
BlockTimeLabel(
label = timeLabel,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
)
}
}
}
}
@Composable
private fun DayLoading() {
val scale = LocalTimelineZoom.current.scale
val scrollState = rememberScrollState()
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Same scale resolution as the loaded timeline, so the skeleton's column
// doesn't resize the moment the real day arrives.
val totalHeight = scale.hourHeight(maxHeight) * 24
Row(
modifier = Modifier
.fillMaxSize()
.padding(end = TIMELINE_CONTENT_END_INSET)
.verticalScroll(scrollState),
) {
Spacer(Modifier.width(GUTTER_WIDTH))
Box(
modifier = Modifier
.weight(1f)
.height(totalHeight)
.padding(horizontal = 2.dp)
.background(MaterialTheme.colorScheme.surfaceContainer),
)
}
}
}
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
formatMinuteOfDay(min, is24Hour, locale)
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
formatCalendarTitle(
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
locale = locale,
currentYear = currentYear,
skeleton = "EEEdMMM",
)
@@ -1,25 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.week.AllDaySpan
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.datetime.LocalDate
/**
* The day view is a single-column slice of the week view (spec S3). It reuses the
* week's [TimedBlock] and [AllDaySpan] layout primitives — for one day, all-day
* spans collapse to a single column ([AllDaySpan.startCol] == [AllDaySpan.endCol]
* == 0) and only their [AllDaySpan.lane] (vertical stacking) matters.
*/
sealed interface DayUiState {
data object Loading : DayUiState
data class Failure(val reason: FailureReason) : DayUiState
data class Success(
val date: LocalDate,
val today: LocalDate,
/** All-day/multi-day events covering this day, stacked by lane. */
val allDay: List<AllDaySpan>,
/** Timed events clipped to this day with overlap lanes resolved. */
val timed: List<TimedBlock>,
) : DayUiState
}
@@ -1,111 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class DayViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date
private val _date = MutableStateFlow(todayDate)
val date: StateFlow<LocalDate> = _date
val state: StateFlow<DayUiState> = _date
.flatMapLatest { day ->
val range = dayRange(day, zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(day, calendars, instances)
}
}
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = DayUiState.Loading,
)
fun goToPrev() {
_date.value = _date.value.minus(1, DateTimeUnit.DAY)
}
fun goToNext() {
_date.value = _date.value.plus(1, DateTimeUnit.DAY)
}
fun goToToday() {
_date.value = todayDate
}
/** Jump to a specific date (e.g. when opened from the month grid). */
fun goToDate(date: LocalDate) {
_date.value = date
}
private fun buildState(
day: LocalDate,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): DayUiState {
if (calendars.isEmpty()) {
return DayUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val days = listOf(day)
val allDay = instances.filter { it.isAllDay }
val timed = instances.filterNot { it.isAllDay }
return DayUiState.Success(
date = day,
today = todayDate,
allDay = layoutAllDay(allDay, days, zone),
timed = layoutDay(timed, day, zone),
)
}
}
/** Half-open instant range covering the single calendar [date]. */
internal fun dayRange(date: LocalDate, zone: TimeZone): ClosedRange<Instant> {
val from = date.atStartOfDayIn(zone)
val to = date.atTime(23, 59, 59).toInstant(zone)
return from..to
}
@@ -1,65 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* A live, scaled-down Day view for the onboarding view chooser. Today's date is
* real; only the events are stand-ins.
*/
@Composable
internal fun DayViewPreview(
height: Dp,
modifier: Modifier = Modifier,
) {
val zone = remember { TimeZone.currentSystemDefault() }
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
val state = remember(today, zone) { sampleDayState(today, zone) }
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
// Half the scroll range is noon — the same centring the live view does.
scrollState.scrollTo(scrollState.maxValue / 2)
}
ScaledViewPreview(height = height, modifier = modifier) {
DaySuccess(
state = state,
topSectionColor = MaterialTheme.colorScheme.surface,
scrollState = scrollState,
allDayHeight = state.allDayStripHeight(),
dragController = rememberTimelineDragController(),
onEventClick = {},
onCreateAt = { _, _ -> },
onDrop = {},
)
}
}
/** Sample day state, laid out through the same helpers the live view uses. */
private fun sampleDayState(today: LocalDate, zone: TimeZone): DayUiState.Success {
val days = listOf(today)
val events = sampleTimelineEvents(days, today, zone)
return DayUiState.Success(
date = today,
today = today,
allDay = layoutAllDay(events.filter { it.isAllDay }, days, zone),
timed = layoutDay(events, today, zone),
)
}
@@ -9,7 +9,6 @@ import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -98,6 +97,9 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
@@ -138,6 +140,7 @@ fun EventDetailScreen(
onBack: () -> Unit,
onEdit: () -> Unit,
onDuplicate: (EventForm) -> Unit,
visible: Boolean = true,
viewModel: EventDetailViewModel = hiltViewModel(),
) {
LaunchedEffect(eventId, beginMillis, endMillis) {
@@ -150,6 +153,7 @@ fun EventDetailScreen(
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
val copyField = rememberFieldCopier(snackbarHostState)
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
// an .ics in the cache and hands back a content Uri for the chooser.
@@ -224,7 +228,7 @@ fun EventDetailScreen(
}
Scaffold(
modifier = Modifier.predictiveBack(onBack = onBack),
modifier = Modifier.predictiveBack(onBack = onBack, visible = visible),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
@@ -299,8 +303,9 @@ fun EventDetailScreen(
is EventDetailUiState.Failure -> CalendarFailure(
reason = s.reason,
onRetry = viewModel::retry,
modifier = contentModifier,
)
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier)
}
}
@@ -382,7 +387,11 @@ private fun DeleteEventDialog(
@Composable
private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
private fun EventDetailContent(
state: EventDetailUiState.Success,
copyField: FieldCopier,
modifier: Modifier = Modifier,
) {
val detail = state.detail
val instance = detail.instance
val dark = isSystemInDarkTheme()
@@ -399,6 +408,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
// event, so it's left implicit — only Free is worth surfacing. A
// cancelled event strikes through its title.
Row(verticalAlignment = Alignment.Top) {
val titleLabel = stringResource(R.string.event_detail_title)
Text(
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
style = MaterialTheme.typography.headlineMedium,
@@ -408,7 +418,17 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
} else {
null
},
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.then(
// Nothing to copy off an untitled event — the placeholder
// isn't the event's own text.
if (instance.title.isNotBlank()) {
Modifier.copyOnLongPress(titleLabel, instance.title, copyField)
} else {
Modifier
},
),
)
if (detail.availability == Availability.Free) {
Spacer(Modifier.width(12.dp))
@@ -508,7 +528,8 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
iconContentDescription = stringResource(R.string.event_detail_calendar),
) {
Text(
text = state.calendarName ?: stringResource(R.string.event_detail_calendar_unknown),
text = state.calendarName?.ifBlank { stringResource(R.string.calendar_unnamed) }
?: stringResource(R.string.event_detail_calendar_unknown),
style = MaterialTheme.typography.titleMedium,
)
}
@@ -516,10 +537,11 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
// Location (conditional, tap → maps).
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
val context = LocalContext.current
val locationLabel = stringResource(R.string.event_detail_location)
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.Default.Place,
iconContentDescription = stringResource(R.string.event_detail_location),
iconContentDescription = locationLabel,
) {
Text(
text = location,
@@ -527,7 +549,12 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.clickable { openInMaps(context, location) }
.copyOnLongPress(
label = locationLabel,
text = location,
copy = copyField,
onTap = { openInMaps(context, location) },
)
.padding(vertical = 2.dp),
)
}
@@ -535,10 +562,15 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
// Description (conditional). URLs are auto-linked.
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
val descriptionLabel = stringResource(R.string.event_detail_description)
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.AutoMirrored.Filled.Notes,
iconContentDescription = stringResource(R.string.event_detail_description),
iconContentDescription = descriptionLabel,
// The gesture sits on the card so the icon and padding answer
// it too: every linkified URL owns the pointer over its own
// glyphs, which leaves the text itself a patchy target.
modifier = Modifier.copyOnLongPress(descriptionLabel, description, copyField),
) {
Text(
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
@@ -611,13 +643,14 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
private fun DetailCard(
icon: ImageVector,
iconContentDescription: String?,
modifier: Modifier = Modifier,
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
@@ -90,6 +91,7 @@ import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.pluralStringResource
@@ -101,6 +103,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
@@ -200,6 +203,7 @@ fun EventEditScreen(
initialForm: EventForm? = null,
initialFormSource: ImportSource = ImportSource.File,
onManageCalendars: (() -> Unit)? = null,
visible: Boolean = true,
viewModel: EventEditViewModel = hiltViewModel(),
) {
LaunchedEffect(initialDateIso, editKey, initialForm) {
@@ -282,7 +286,7 @@ fun EventEditScreen(
}
Scaffold(
modifier = Modifier.predictiveBack(onBack = close),
modifier = Modifier.predictiveBack(onBack = close, visible = visible),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
@@ -617,7 +621,9 @@ private fun EventEditContent(
Text(
text = stringResource(
R.string.event_edit_managed_hint,
selectedCalendar?.displayName.orEmpty(),
selectedCalendar?.displayName
?.ifBlank { stringResource(R.string.calendar_unnamed) }
.orEmpty(),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -716,6 +722,7 @@ private fun EventEditContent(
) {
Text(
text = selectedCalendar?.displayName
?.ifBlank { stringResource(R.string.calendar_unnamed) }
?: stringResource(R.string.event_edit_error_no_calendar),
style = MaterialTheme.typography.titleMedium,
color = if (selectedCalendar == null) {
@@ -788,38 +795,59 @@ private fun EventEditContent(
EditCard(
icon = Icons.Default.Place,
iconContentDescription = stringResource(R.string.event_detail_location),
iconAtTop = true,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.Top) {
InlineField(
value = form.location,
onValueChange = viewModel::setLocation,
placeholder = stringResource(R.string.event_detail_location),
// A location is as often a meeting URL as an address,
// and "Zoom.us/j/123" reads wrong (#146).
capitalization = KeyboardCapitalization.None,
// Multi-line so a long address or meeting URL wraps and
// the card grows instead of scrolling off one line
// (#273). The field holds no newlines of its own, so
// the IME's action key closes the keyboard rather than
// inserting a break setLocation would only fold away.
singleLine = false,
imeAction = ImeAction.Done,
modifier = Modifier
.weight(1f)
.animateContentSizeMotion()
.padding(vertical = 4.dp),
)
IconButton(
onClick = {
runCatching {
pickContactAddress.launch(
Intent(
Intent.ACTION_PICK,
ContactsContract.CommonDataKinds.StructuredPostal
.CONTENT_URI,
),
)
}
},
modifier = Modifier.size(40.dp),
// The box is exactly one text line tall, so the button
// centres on the first line without making a single-line
// card taller than its text; requiredSize keeps the
// button's own 40dp ripple.
Box(
modifier = Modifier.height(firstLineHeight()),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.Contacts,
contentDescription = stringResource(
R.string.event_edit_location_from_contacts,
),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
IconButton(
onClick = {
runCatching {
pickContactAddress.launch(
Intent(
Intent.ACTION_PICK,
ContactsContract.CommonDataKinds.StructuredPostal
.CONTENT_URI,
),
)
}
},
modifier = Modifier.requiredSize(40.dp),
) {
Icon(
imageVector = Icons.Default.Contacts,
contentDescription = stringResource(
R.string.event_edit_location_from_contacts,
),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
}
}
}
@@ -1989,8 +2017,8 @@ private fun readContactEmail(context: Context, uri: Uri): Pair<String, String>?
/**
* Read the formatted postal address from a contact-pick result URI. No
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
* The provider's formatted address is multi-line; collapse it to one line so
* it sits cleanly in the single-line location field.
* The provider's formatted address is multi-line; collapse it to one comma-
* separated line, which is what the location field holds.
*/
private fun readContactAddress(context: Context, uri: Uri): String? =
context.contentResolver.query(
@@ -2174,14 +2202,19 @@ private fun EditCard(
modifier = Modifier.padding(16.dp),
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
) {
// A top-aligned icon centres on the first text line, whatever
// that line measures at the user's font scale.
val iconOffset = if (iconAtTop) {
((firstLineHeight() - 24.dp) / 2).coerceAtLeast(0.dp)
} else {
0.dp
}
Icon(
imageVector = icon,
contentDescription = iconContentDescription,
tint = iconTint,
// 4dp mirrors InlineField's vertical padding, so a
// top-aligned icon (24dp) centres on the ~24sp first line.
modifier = Modifier
.padding(top = if (iconAtTop) 4.dp else 0.dp)
.padding(top = iconOffset)
.size(24.dp),
)
Spacer(Modifier.width(16.dp))
@@ -2223,7 +2256,9 @@ private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Lo
/**
* Borderless text input used inside the cards (and as the headline title).
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
* the app share one input style.
* the app share one input style. Sentence-case by default, as floret-kit is;
* a field holding an identifier rather than prose passes
* [KeyboardCapitalization.None].
*/
@Composable
private fun InlineField(
@@ -2235,6 +2270,8 @@ private fun InlineField(
minLines: Int = 1,
enabled: Boolean = true,
keyboardType: KeyboardType = KeyboardType.Text,
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
imeAction: ImeAction = ImeAction.Default,
modifier: Modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
@@ -2249,10 +2286,25 @@ private fun InlineField(
minLines = minLines,
enabled = enabled,
keyboardType = keyboardType,
capitalization = KeyboardCapitalization.None,
capitalization = capitalization,
imeAction = imeAction,
)
}
/**
* Height of one [InlineField] line: the resolved [TextStyle.lineHeight] plus the
* field's 4dp vertical padding. In dp, so it tracks the user's font scale
* instead of assuming the 24sp line of a 1.0 scale (#165).
*/
@Composable
private fun firstLineHeight(
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
): Dp {
val lineHeight = textStyle.lineHeight
if (!lineHeight.isSp) return 32.dp
return with(LocalDensity.current) { lineHeight.toDp() } + 8.dp
}
/** One schedule row: label, then tappable date and (unless all-day) time. */
@Composable
private fun ScheduleRow(
@@ -59,6 +59,9 @@ import javax.inject.Inject
private const val TAG = "EventEdit"
/** Any line break, with the whitespace around it, as one match. */
private val LINE_BREAK = Regex("""[ \t]*\R[ \t]*""")
/**
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
* want different reminder handling (#49), and differ in whether they own the
@@ -532,7 +535,11 @@ class EventEditViewModel @Inject constructor(
// paste would introduce, so it never reaches the provider's TITLE column.
fun setTitle(value: String) =
update { it.copy(title = value.replace("\n", "").replace("\r", "")) }
fun setLocation(value: String) = update { it.copy(location = value) }
// The location wraps too (#273) but is likewise one logical line. A pasted
// multi-line address joins the way the contact picker's does (#146), so it
// never runs together into "12 Main St10115 Berlin".
fun setLocation(value: String) =
update { it.copy(location = value.replace(LINE_BREAK, ", ")) }
fun setDescription(value: String) = update { it.copy(description = value) }
fun setAllDay(value: Boolean) {
// Going all-day drops any pinned zone: the times become bare dates that
@@ -62,15 +62,16 @@ private fun FilterList(
) {
Column(modifier = modifier.fillMaxWidth()) {
groups.forEach { group ->
val account = group.account.ifBlank { stringResource(R.string.calendar_unnamed) }
Text(
text = if (group.ambiguous) {
stringResource(
R.string.calendars_account_from_source,
group.account,
account,
sourceAppName(group.accountType),
)
} else {
group.account
account
},
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -83,7 +84,7 @@ private fun FilterList(
)
group.calendars.forEachIndexed { index, cal ->
GroupedRow(
title = cal.displayName,
title = cal.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
position = positionOf(index, group.calendars.size),
minHeight = 56.dp,
leading = { CalendarColorChip(cal.color) },
@@ -125,6 +126,7 @@ private fun FilterLoading(modifier: Modifier = Modifier) {
private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) {
val msg = when (reason) {
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden
FailureReason.PermissionRevoked -> R.string.state_failure_permission
else -> R.string.state_failure_provider
}
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.SharingStarted
@@ -35,8 +36,9 @@ class FilterViewModel @Inject constructor(
// Calendars switched off device-wide don't belong in the drawer's
// hide/show list; they live only in Settings → Calendars.
val enabled = calendars.filter { it.isVisibleInSystem }
if (enabled.isEmpty()) {
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
val failure = calendarListFailure(calendars)
if (failure != null) {
FilterUiState.Failure(failure)
} else {
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
}
@@ -73,19 +73,20 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
@Composable
fun ImportScreen(
uri: Uri,
session: Int,
onClose: () -> Unit,
onOpenSingle: (EventForm) -> Unit,
forceMany: Boolean = false,
onManageCalendars: (() -> Unit)? = null,
// Key the VM by the file uri. This screen has no nav backstack, so an
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
// across imports — its one-shot `load` guard would then show the *previous*
// file's parsed state on the next import (a second restore, export→restore,
// etc.). Keying per uri hands each distinct file a fresh VM (fresh Loading
// state), while the same uri (rotation) reuses it and holds the result.
viewModel: ImportViewModel = hiltViewModel(key = uri.toString()),
visible: Boolean = true,
viewModel: ImportViewModel = hiltViewModel(),
) {
LaunchedEffect(uri) { viewModel.load(uri, forceMany) }
// hiltViewModel() resolves to the Activity's store and is retained across
// imports, so the reload is driven by [session] rather than by a fresh VM:
// keying per uri looked right but handed a *re-import of the same file* the
// previous run's finished state, importing nothing (#304). A rotation keeps
// the session and so keeps the result.
LaunchedEffect(session) { viewModel.load(uri, forceMany, session) }
val state by viewModel.state.collectAsStateWithLifecycle()
// A single event isn't shown here — it opens the create form for review.
@@ -103,7 +104,7 @@ fun ImportScreen(
Scaffold(
modifier = Modifier
.predictiveBack(onBack = onClose)
.predictiveBack(onBack = onClose, visible = visible)
.fillMaxSize(),
topBar = {
TopAppBar(
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
import de.jeanlucmakiola.calendula.domain.isEventTarget
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -69,18 +70,26 @@ class ImportViewModel @Inject constructor(
private val parser = IcsParser()
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
val state: StateFlow<ImportUiState> = _state.asStateFlow()
private var started = false
private var loadedSession: Int? = null
private var loadJob: Job? = null
/**
* Read + parse [uri] once; subsequent calls (recomposition) are ignored.
* Read + parse [uri] once per [session]; recomposition (and a rotation,
* which keeps the session) re-calls this and keeps the result. A new import
* of the *same* file is a new session, and has to parse and run again —
* keying on the uri alone showed the previous run's summary and imported
* nothing (#304).
*
* When [forceMany] is set (an in-app restore), a single-event file still goes
* through the bulk picker + summary rather than the prefilled create form —
* a restore is "bring back a backup", not "add this one event".
*/
fun load(uri: Uri, forceMany: Boolean = false) {
if (started) return
started = true
viewModelScope.launch {
fun load(uri: Uri, forceMany: Boolean = false, session: Int = 0) {
if (loadedSession == session) return
loadedSession = session
loadJob?.cancel()
_state.value = ImportUiState.Loading
loadJob = viewModelScope.launch {
val parsed = withContext(io) {
importer.readText(uri)?.let(parser::parse)
}
@@ -0,0 +1,61 @@
package de.jeanlucmakiola.calendula.ui.licences
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.positionOf
/**
* Third-party attribution.
*
* Reachable from Settings rather than buried, because Apache-2.0 §4(a) is about
* what the *recipient of the binary* is told. Each row opens the project's source.
*/
@Composable
internal fun LicencesScreen(onBack: () -> Unit, visible: Boolean = true) {
val uriHandler = LocalUriHandler.current
CollapsingScaffold(
title = stringResource(R.string.licences_title),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
Text(
stringResource(R.string.licences_intro),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Spacer(Modifier.height(16.dp))
OpenSourceLicences.ALL.forEachIndexed { index, attribution ->
GroupedRow(
title = attribution.name,
summary = "${attribution.copyright} · ${attribution.licence.spdxId}",
position = positionOf(index, OpenSourceLicences.ALL.size),
onClick = { uriHandler.openUri(attribution.sourceUrl) },
)
}
Spacer(Modifier.height(24.dp))
Text(
stringResource(R.string.licences_footer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Spacer(Modifier.height(24.dp))
}
}
@@ -0,0 +1,68 @@
package de.jeanlucmakiola.calendula.ui.licences
/**
* The third-party code Calendula ships, and what each licence obliges us to say.
*
* This screen is a **licence obligation, not an about-page nicety**:
* **Apache-2.0 §4(a)** requires the licence to travel with the work and **§4(d)**
* propagates any `NOTICE` file. What discharges that is what the recipient of the
* APK is shown — a notice only visible to someone reading the repository does not
* count. Nearly everything below is Apache-2.0.
*
* Hand-maintained on purpose. Generators read POM metadata, and POM metadata is
* routinely wrong — the licence name is often not an SPDX id and the licence URL
* frequently 404s, which produces confidently incomplete output. A short honest
* list beats a long generated one that omits the entry that mattered.
*
* **When adding a dependency, add it here.** There is no automated check that can
* tell you that you forgot. Test-only dependencies (JUnit, Truth, Turbine) are not
* shipped in the APK and stay off the list.
*/
data class Attribution(
val name: String,
val copyright: String,
val licence: Licence,
/** Where the source can actually be obtained; what each row links to. */
val sourceUrl: String,
)
enum class Licence(val spdxId: String, val url: String) {
APACHE_2("Apache-2.0", "https://www.apache.org/licenses/LICENSE-2.0"),
MIT("MIT", "https://opensource.org/license/mit"),
}
object OpenSourceLicences {
val ALL: List<Attribution> = listOf(
Attribution(
name = "AndroidX, Jetpack Compose, Glance, WorkManager, DataStore, DocumentFile",
copyright = "© The Android Open Source Project",
licence = Licence.APACHE_2,
sourceUrl = "https://cs.android.com/androidx/platform/frameworks/support",
),
Attribution(
name = "Kotlin, kotlinx.coroutines, kotlinx.datetime",
copyright = "© JetBrains s.r.o. and Kotlin Programming Language contributors",
licence = Licence.APACHE_2,
sourceUrl = "https://github.com/JetBrains/kotlin",
),
Attribution(
name = "Dagger and Hilt",
copyright = "© Google LLC and The Dagger Authors",
licence = Licence.APACHE_2,
sourceUrl = "https://github.com/google/dagger",
),
Attribution(
name = "Material Design icons",
copyright = "© Google LLC",
licence = Licence.APACHE_2,
sourceUrl = "https://github.com/google/material-design-icons",
),
Attribution(
name = "floret-kit",
copyright = "© Jean-Luc Makiola",
licence = Licence.MIT,
sourceUrl = "https://codeberg.org/jlmakiola/floret-kit",
),
)
}
@@ -0,0 +1,25 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.ui.unit.dp
// The month grid's chip geometry, together in one file: these derive from each
// other, and a top-level val reading one across a file boundary would resolve
// against whichever class the JVM happened to initialise first.
/** Gap between a day cell and its neighbours. */
internal val CELL_GAP = 2.dp
/** Padding between a month chip's edge and its text. */
internal val MONTH_CHIP_TEXT_PADDING = 3.dp
/**
* A chip's own inset inside its day cell. The cell's gap and no more: a day
* column on a phone is around fifty dp, and the chip was spending a quarter of
* it on chrome before a single glyph. The cells keep their full separation from
* each other — what the grid reads as breathing room — and only the chip inside
* one takes the width back (#212).
*/
internal val MONTH_CHIP_INSET = CELL_GAP
/** Horizontal space a chip spends on chrome rather than on text, both sides. */
internal val MONTH_CHIP_CHROME = (MONTH_CHIP_INSET + MONTH_CHIP_TEXT_PADDING) * 2f
@@ -0,0 +1,33 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.util.Locale
/**
* 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
* in from an earlier week, whose start is not in this segment.
*/
internal fun monthChipTime(
event: EventInstance,
continuesLeft: Boolean,
zone: TimeZone,
is24Hour: Boolean,
locale: Locale,
): String? {
if (event.isAllDay || continuesLeft) return null
val start = event.start.toLocalDateTime(zone).time
return formatTimeOfDay(start.hour, start.minute, is24Hour, locale)
}
/** The narrowest chip that may show a time — the text it needs, plus its chrome. */
@Composable
internal fun rememberMonthTimeChipWidth(): Dp =
rememberInlineTimeWidth(MaterialTheme.typography.labelSmall) + MONTH_CHIP_CHROME
@@ -97,6 +97,13 @@ data class MonthChipDrag(
val targetDate: LocalDate?,
val topLeftInRoot: Offset,
val sizePx: IntSize,
/**
* The start time of the chip this lifted off, already formatted (#219).
* Carried rather than re-derived: the overlay has no row to ask whether the
* bar was carried in from the previous week, and no zone the grid laid out
* in. Null where the source chip had no time to show.
*/
val time: String? = null,
)
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
@@ -172,6 +179,7 @@ class MonthDragController {
private var event: EventInstance? = null
private var grabDate: LocalDate? = null
private var time: String? = null
private var grab = Offset.Zero
private var pointer = Offset.Zero
private var sizePx = IntSize.Zero
@@ -190,9 +198,11 @@ class MonthDragController {
pointerInRoot: Offset,
chipInRoot: Offset,
size: IntSize,
time: String? = null,
) {
this.event = event
this.grabDate = grabDate
this.time = time
settling = null
isDragging = true
liftedInstanceId = event.instanceId
@@ -210,6 +220,7 @@ class MonthDragController {
fun cancel() {
event = null
grabDate = null
time = null
isDragging = false
liftedInstanceId = null
drag = null
@@ -331,6 +342,7 @@ class MonthDragController {
targetDate = resolved ?: drag?.targetDate,
topLeftInRoot = pointer - grab,
sizePx = sizePx,
time = time,
)
}
}
File diff suppressed because it is too large Load Diff
@@ -74,7 +74,7 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
*/
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
if (col !in days.indices || lane !in 0 until laneCap) return null
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.let { return it.event }
spanAt(col, lane)?.let { return it.event }
val occupied = spans
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
.map { it.lane }
@@ -91,8 +91,11 @@ fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
* [col] for a single-day chip, and for an empty slot no one should be asking
* about.
*/
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int =
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.startCol ?: col
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int = spanAt(col, lane)?.startCol ?: col
/** The bar covering lane [lane] of column [col], or null where a pill sits there. */
fun MonthWeek.spanAt(col: Int, lane: Int): MonthSpan? =
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
/**
* The events on [day] that [laneEvents] had no lane left for — its exact
@@ -136,6 +139,8 @@ sealed interface ContinuousMonthUiState {
val monthsByIndex: Map<Int, List<MonthWeek>>,
val weeksByIndex: Map<Int, MonthWeek>,
val weekStart: DayOfWeek,
/** As [MonthUiState.Success.zone], and for the same reason. */
val zone: TimeZone = TimeZone.currentSystemDefault(),
) : ContinuousMonthUiState
}
@@ -10,6 +10,10 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
@@ -23,6 +27,9 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.map
import kotlin.math.abs
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
@@ -42,6 +49,9 @@ import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
/** How many months either side of the requested one [MonthViewModel.month] keeps cached. */
private const val MONTH_CACHE_SPAN = 2
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class MonthViewModel @Inject constructor(
@@ -85,6 +95,21 @@ class MonthViewModel @Inject constructor(
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
val month: StateFlow<YearMonth> = _month
/** The pager page the anchor month sits on, for the paged and split styles. */
val anchorPage: StateFlow<Int> = _month
.map { monthIndexOf(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = monthIndexOf(_month.value),
)
/**
* The anchor month's state. Once loaded it never falls back to
* [MonthUiState.Loading]: moving the anchor keeps the last result until the
* new month arrives, since the pages draw their own months and this feeds
* the failure screen and the split style's day pane.
*/
val state: StateFlow<MonthUiState> =
combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) }
.flatMapLatest { (ym, ws, style) ->
@@ -92,22 +117,48 @@ class MonthViewModel @Inject constructor(
// Split draw, so under a scrolling style this query is a month
// of provider work for a view that isn't on screen.
if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading)
val range = monthGridRange(ym, ws, zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(ym, ws, calendars, instances)
}
month(ym, ws).filterNot { it is MonthUiState.Loading }
}
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = MonthUiState.Loading,
)
private val months = PageStateCache<Pair<YearMonth, DayOfWeek>, MonthUiState>(
scope = viewModelScope,
initial = MonthUiState.Loading,
// Far enough to cover the pages either side and a swipe back, so paging
// through a year doesn't keep a year of queries around. A week-start
// change lays every month out afresh.
keep = { (cached, cachedWs), (requested, ws) ->
cachedWs == ws && abs(monthIndexOf(cached) - monthIndexOf(requested)) <= MONTH_CACHE_SPAN
},
) { (ym, ws) ->
combine(
repository.calendars(),
repository.instances(monthGridRange(ym, ws, zone)),
) { calendars, instances ->
buildState(ym, ws, calendars, instances)
}
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
}
/**
* Month [ym] laid out with weeks starting on [ws], shared between its pager
* page and [state]. Main thread only.
*/
fun month(ym: YearMonth, ws: DayOfWeek): StateFlow<MonthUiState> = months.get(ym to ws)
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
fun onPageSettled(page: Int) {
if (monthIndexOf(_month.value) == page) return
_month.value = yearMonthForIndex(page)
realignSelection()
}
// --- Continuous + Dense styles (#38) ----------------------------------
//
// These scroll through every month there is, so they can't load "a month" —
@@ -207,9 +258,7 @@ class MonthViewModel @Inject constructor(
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): ContinuousMonthUiState {
if (calendars.isEmpty()) {
return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured)
}
calendarListFailure(calendars)?.let { return ContinuousMonthUiState.Failure(it) }
// Bucketed once for the whole window: a row then takes the handful of
// events on its seven days instead of re-scanning a year of them.
val byDay = DayIndex(instances, zone)
@@ -238,17 +287,16 @@ class MonthViewModel @Inject constructor(
monthsByIndex = months,
weeksByIndex = weeks,
weekStart = weekStart,
zone = zone,
)
}
// --- Split style (#53) ------------------------------------------------
//
// The first selected-day concept in the app: the other views drill straight
// into a date, while the split style keeps one selected and lists it below
// the grid.
// Null means the full-month agenda; a date narrows the pane to that day.
private val _selectedDate = MutableStateFlow(todayDate)
val selectedDate: StateFlow<LocalDate> = _selectedDate
private val _selectedDate = MutableStateFlow<LocalDate?>(null)
val selectedDate: StateFlow<LocalDate?> = _selectedDate
/**
* Select [date], following it to its month if it sits in the grid's leading
@@ -256,33 +304,33 @@ class MonthViewModel @Inject constructor(
* silently list a date the grid isn't pointing at.
*/
fun selectDate(date: LocalDate) {
_selectedDate.value = date
val target = YearMonth(date.year, date.month)
_selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date
if (target != _month.value) _month.value = target
}
/**
* Move the selection with the month: today if the new month holds it, else
* its 1st. Leaving the old date selected would list a day the grid no longer
* shows.
*/
fun clearSelection() { _selectedDate.value = null }
/** A pane swipe walks days when filtered, months when showing the agenda. */
fun stepSplitPane(days: Int) {
val selected = _selectedDate.value
if (selected == null) {
_month.value = yearMonthForIndex(monthIndexOf(_month.value) + days)
} else {
val next = selected.plus(days, DateTimeUnit.DAY)
_selectedDate.value = next
_month.value = YearMonth(next.year, next.month)
}
}
/** Moving the grid to another month restores its full agenda. */
private fun realignSelection() {
_selectedDate.value = selectionForMonth(_month.value, todayDate)
}
fun goToPrev() {
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
realignSelection()
}
fun goToNext() {
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
realignSelection()
_selectedDate.value = null
}
fun goToToday() {
_month.value = YearMonth(todayDate.year, todayDate.month)
_selectedDate.value = todayDate
_selectedDate.value = null
}
/**
@@ -291,13 +339,12 @@ class MonthViewModel @Inject constructor(
* otherwise sit wherever paged navigation last left it (today's month, on a
* fresh open) — and a switch to a paged style, or a reseed of the other
* scrolling style's list, would jump there instead of holding position. The
* selection is realigned alongside it so that landing on the Split style
* shows a live day in the month on screen rather than a stale, off-month one.
* selection is cleared alongside it so Split opens the visible month's agenda.
*/
fun syncScrollMonth(ym: YearMonth) {
if (_month.value == ym) return
_month.value = ym
_selectedDate.value = selectionForMonth(ym, todayDate)
_selectedDate.value = null
}
/** Jump to the month containing [date] (drawer jump-to-date). */
@@ -312,9 +359,7 @@ class MonthViewModel @Inject constructor(
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): MonthUiState {
if (calendars.isEmpty()) {
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
}
calendarListFailure(calendars)?.let { return MonthUiState.Failure(it) }
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
return MonthUiState.Success(
month = ym,
@@ -438,14 +483,18 @@ internal fun layoutCalendarWeek(
days = days,
spans = spans,
timedByDay = days.associateWith { d ->
singles.filter { it.coversDay(d, zone) }.sortedBy { it.start }
// Declined last, so a day that overflows drops the events you said
// no to before the ones you are actually going to (#230).
singles.filter { it.coversDay(d, zone) }
.sortedWith(compareBy<EventInstance> { it.isDeclined }.thenBy { it.start })
},
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
)
}
/**
* Every event touching each of [days], all-day first then by start time. Unlike
* Every event touching each of [days], declined ones last (#230), then all-day
* first and by start time within each group. Unlike
* [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date
* they cover and applies no display cap, so the split style's day pane can list a
* date in full without querying the provider again.
@@ -458,7 +507,7 @@ internal fun instancesByDay(
days.associateWith { day ->
instances
.filter { it.coversDay(day, zone) }
.sortedWith(compareByDescending<EventInstance> { it.isAllDay }.thenBy { it.start })
.sortedWith(eventDisplayComparator(zone))
}
/**
@@ -499,11 +548,10 @@ private const val WINDOW_EDGE = 2
internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0)
/**
* Which day the split style should select when the grid lands on [month]:
* [today] when the month holds it, otherwise the 1st. Pure so the rule can be
* tested without standing up a view model and a provider behind it.
* First day shown in the split agenda: today in the current month, otherwise
* the first of the visible month.
*/
internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate =
internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate =
if (YearMonth(today.year, today.month) == month) {
today
} else {
@@ -0,0 +1,224 @@
package de.jeanlucmakiola.calendula.ui.onboarding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.CloudSync
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.calendars.CalendarEditor
import de.jeanlucmakiola.calendula.ui.settings.openUrl
import de.jeanlucmakiola.floret.components.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
/** Where the sync branch sends someone who needs a CalDAV client (#287). */
private const val DAVX5_URL = "https://www.davx5.com/"
/**
* Wizard step shown only when the device has no calendar at all (#287): the app
* would otherwise finish onboarding onto an empty grid with nothing saying why.
*
* Two ways out. Calendula writes to the system calendar provider and syncs
* nothing itself, so anything beyond a device-only calendar means pointing at
* whatever does the syncing.
*
* [creation] switches the step to the outcome of its own create: confirming
* the calendar — the one place to say that more of them live in Settings — or
* reporting that the provider refused it.
*/
@Composable
internal fun CalendarsStep(
onCreateLocal: (name: String, color: Int, description: String?) -> Unit,
onSkip: () -> Unit,
creation: CalendarCreation?,
onFinished: () -> Unit,
onClearCreation: () -> Unit,
modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null,
navigationIcon: (@Composable () -> Unit)? = null,
) {
val context = LocalContext.current
// The same editor Settings opens, rather than a calendar conjured out of
// nowhere: "create a calendar" should let you name and colour it, and the
// step is the first place anyone meets one.
var creating by rememberSaveable { mutableStateOf(false) }
// The insert is a suspend write: the editor stays up until it lands, so the
// step can't flash its "create" button back for a second press.
var submitted by rememberSaveable(creating) { mutableStateOf(false) }
LaunchedEffect(creation) { if (creation is CalendarCreation.Failed) creating = false }
if (creation is CalendarCreation.Created) {
CalendarCreated(
name = creation.name,
onContinue = onFinished,
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
)
return
}
if (creating) {
CalendarEditor(
sessionKey = 0,
isNew = true,
// Empty, not a suggestion: a name already in the field reads as one
// the app has chosen, and the first thing to do is clear it.
initialName = "",
initialColor = CalendarColorPalette.all.first(),
initialDescription = "",
onSave = { name, color, description ->
if (!submitted) {
submitted = true
onCreateLocal(name, color, description)
}
},
onDelete = {},
onClose = { creating = false },
)
return
}
OnboardingScaffold(
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.CalendarMonth) },
actions = {
Button(
onClick = {
onClearCreation()
creating = true
},
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.onboarding_calendars_local_button),
style = MaterialTheme.typography.titleMedium,
)
}
OutlinedButton(
onClick = { openUrl(context, DAVX5_URL) },
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(stringResource(R.string.onboarding_calendars_caldav_button))
}
TextButton(
onClick = onSkip,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.onboarding_calendars_skip_button))
}
},
) {
Text(
text = stringResource(R.string.onboarding_calendars_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_calendars_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
if (creation is CalendarCreation.Failed) {
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_calendars_create_failed),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(OnboardingSpace.lg))
BenefitRow(
icon = Icons.Filled.PhoneAndroid,
title = stringResource(R.string.onboarding_calendars_benefit_local_title),
body = stringResource(R.string.onboarding_calendars_benefit_local_body),
)
Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow(
icon = Icons.Filled.CloudSync,
title = stringResource(R.string.onboarding_calendars_benefit_sync_title),
body = stringResource(R.string.onboarding_calendars_benefit_sync_body),
)
}
}
/** What the step shows once the calendar exists: that it does, and where the next one comes from. */
@Composable
private fun CalendarCreated(
name: String,
onContinue: () -> Unit,
modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null,
navigationIcon: (@Composable () -> Unit)? = null,
) {
OnboardingScaffold(
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.CheckCircle) },
actions = {
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.onboarding_calendars_created_continue),
style = MaterialTheme.typography.titleMedium,
)
}
},
) {
Text(
text = stringResource(R.string.onboarding_calendars_created_title, name),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_calendars_created_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(OnboardingSpace.lg))
BenefitRow(
icon = Icons.Filled.Settings,
title = stringResource(R.string.onboarding_calendars_created_more_title),
body = stringResource(R.string.onboarding_calendars_created_more_body),
)
}
}
@@ -0,0 +1,173 @@
package de.jeanlucmakiola.calendula.ui.onboarding
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
import de.jeanlucmakiola.floret.components.Position
/**
* Wizard step for a device whose every calendar is switched off (#287), which
* would otherwise open the app on a blank grid with nothing saying why.
*
* The calendars are listed rather than summarised: which ones exist and whose
* account they came from is the whole of what there is to decide, and switching
* them on shows them in every other calendar app too — not a choice to make on
* the app's word. Each row carries the same switch Settings does; the button
* above them does all of it at once.
*
* Declining leads into the calendar step behind this one, which offers to make a
* calendar instead — the same offer a device with none at all gets.
*/
@Composable
internal fun HiddenCalendarsStep(
calendars: List<CalendarSource>,
onSetVisible: (id: Long, visible: Boolean) -> Unit,
onSwitchAllOn: () -> Unit,
onFinished: () -> Unit,
writeFailed: Boolean,
modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null,
navigationIcon: (@Composable () -> Unit)? = null,
) {
val anyVisible = calendars.any { it.isVisibleInSystem }
OnboardingScaffold(
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
topSpacing = OnboardingSpace.sm,
contentPadding = 0.dp,
// The list is as long as the device's calendars; pinned actions would
// eat the room it needs and hide that there is more below.
scrollingActions = true,
hero = { IconHero(Icons.Filled.VisibilityOff) },
actions = {
Button(
onClick = if (anyVisible) onFinished else onSwitchAllOn,
modifier = Modifier
.padding(horizontal = GroupedListInset)
.fillMaxWidth()
.height(56.dp),
) {
Text(
text = stringResource(
if (anyVisible) {
R.string.onboarding_view_continue_button
} else {
R.string.onboarding_hidden_switch_all_button
},
),
style = MaterialTheme.typography.titleMedium,
)
}
// Only while nothing is on: once something is, the step has been
// answered and the one button left to press says so.
if (!anyVisible) {
TextButton(
onClick = onFinished,
modifier = Modifier
.padding(horizontal = GroupedListInset)
.fillMaxWidth(),
) {
Text(stringResource(R.string.onboarding_hidden_skip_button))
}
}
},
) {
Column(Modifier.padding(horizontal = GroupedListInset)) {
Text(
text = stringResource(R.string.onboarding_hidden_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_hidden_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
if (writeFailed) {
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_hidden_failed),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
Spacer(Modifier.height(OnboardingSpace.md))
calendars.forEachIndexed { index, calendar ->
val off = !calendar.isVisibleInSystem
GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
summary = calendar.accountName.takeIf { it.isNotBlank() },
position = when {
calendars.size == 1 -> Position.Alone
index == 0 -> Position.Top
index == calendars.lastIndex -> Position.Bottom
else -> Position.Middle
},
dimmed = off,
trailing = if (calendar.hasVisibilitySwitch) {
{
CalendarSwitch(
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
enabled = !off,
onToggle = { on -> onSetVisible(calendar.id, on) },
)
}
} else {
null
},
leading = { CalendarColorChip(calendar.color) },
)
}
Spacer(Modifier.height(OnboardingSpace.md))
}
}
/** The per-row switch, writing the device's own `Calendars.VISIBLE` as Settings does. */
@Composable
private fun CalendarSwitch(
calendarName: String,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
Switch(
checked = enabled,
onCheckedChange = onToggle,
modifier = Modifier.semantics { contentDescription = label },
)
}
@@ -5,6 +5,19 @@ enum class OnboardingStep {
/** Required: nothing works without the calendar grant. */
Permission,
/**
* Only when every calendar on the device is switched off, so the app would
* open on a blank grid: lists them and offers to switch them back on (#287).
*/
HiddenCalendars,
/**
* Only when the device has nothing to put an event in — no calendar at all,
* or none left showing after the step above. The app can make a local one,
* or send you to whatever would do the syncing (#287).
*/
Calendars,
/** Whether Calendula delivers reminder notifications itself. */
Reminders,
@@ -67,6 +80,18 @@ data class OnboardingPlan(
* otherwise, so the flow can only ever get shorter — never sprout a step the
* counter had not accounted for.
*
* [calendarsApplies] carries the same contract for the calendar step, with one
* wrinkle: for the backup step an empty list means "could not read", but here
* an empty list *is* the condition. The two are indistinguishable at this
* layer and both want the step, so it turns false only once a calendar the app
* could actually show has arrived.
*
* [hiddenApplies] is the step in front of it (#287): the device has calendars
* but every one of them is switched off. It comes first because switching one
* back on is the lighter remedy — and where it is declined, the calendar step
* behind it still offers to make one, the same offer a device with no calendars
* at all gets.
*
* The month-style step is *not* conditional on Month being the chosen view:
* Month is reachable from the drawer whatever opens first, and a step that came
* and went as the view is picked would move the counter under the user on the
@@ -75,7 +100,9 @@ data class OnboardingPlan(
* [visibilityArmed] carries the one-time notice that the device is hiding some
* calendars (#75) — the only optional step an *existing* install can be given,
* which is why it is not gated on `fresh`, and on its own it earns no closing
* screen.
* screen. It stays a pure announcement: the all-hidden *state* is
* [hiddenApplies]'s business, up at the front of the flow where something can
* still be done about it.
*/
fun onboardingPlan(
hasPermission: Boolean,
@@ -85,6 +112,10 @@ fun onboardingPlan(
viewDone: Boolean,
monthStyleDone: Boolean,
backupApplies: Boolean?,
hiddenDone: Boolean = false,
hiddenApplies: Boolean? = false,
calendarsDone: Boolean = false,
calendarsApplies: Boolean? = null,
visibilityArmed: Boolean = false,
visibilityDone: Boolean = false,
doneShown: Boolean = false,
@@ -92,6 +123,8 @@ fun onboardingPlan(
val fresh = wizardArmed || (!hasPermission && !remindersDone)
val steps = buildList {
if (!hasPermission || fresh) add(OnboardingStep.Permission)
if (fresh && hiddenApplies != false) add(OnboardingStep.HiddenCalendars)
if (fresh && calendarsApplies != false) add(OnboardingStep.Calendars)
if (!remindersDone || fresh) add(OnboardingStep.Reminders)
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
if (fresh) {
@@ -104,6 +137,8 @@ fun onboardingPlan(
val current = steps.firstOrNull { step ->
when (step) {
OnboardingStep.Permission -> !hasPermission
OnboardingStep.HiddenCalendars -> !hiddenDone
OnboardingStep.Calendars -> !calendarsDone
OnboardingStep.Reminders -> !remindersDone
OnboardingStep.Backup -> !backupDone
OnboardingStep.View -> !viewDone
@@ -28,6 +28,9 @@ fun OnboardingSteps(
// Collected for every step, not just the two that draw a preview: starting
// it when the picker composes would leave that step blank as it slides in.
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
val creation by viewModel.creation.collectAsStateWithLifecycle()
val hiddenCalendars by viewModel.hiddenCalendars.collectAsStateWithLifecycle()
val hiddenWriteFailed by viewModel.hiddenWriteFailed.collectAsStateWithLifecycle()
// Coerced because the outgoing half of a transition may be a step the live
// plan has since dropped — the backup step goes once the calendars say it
@@ -67,6 +70,26 @@ fun OnboardingSteps(
modifier = modifier,
progress = progress,
)
OnboardingStep.HiddenCalendars -> HiddenCalendarsStep(
calendars = hiddenCalendars,
onSetVisible = viewModel::setHiddenCalendarVisible,
onSwitchAllOn = viewModel::switchAllCalendarsOn,
onFinished = viewModel::finishHiddenCalendars,
writeFailed = hiddenWriteFailed,
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
)
OnboardingStep.Calendars -> CalendarsStep(
onCreateLocal = viewModel::createLocalCalendar,
onSkip = viewModel::skipCalendars,
creation = creation,
onFinished = viewModel::finishCalendars,
onClearCreation = viewModel::clearCalendarCreation,
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
)
OnboardingStep.Reminders -> ReminderStep(
onFinished = viewModel::finishReminders,
modifier = modifier,
@@ -16,6 +16,8 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
import de.jeanlucmakiola.calendula.domain.orderedForManager
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import kotlinx.coroutines.CoroutineDispatcher
@@ -24,6 +26,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
@@ -74,6 +77,27 @@ class OnboardingViewModel @Inject constructor(
initialValue = null,
)
/**
* The calendar list every derived step reads, collected once: one per step
* would put a provider query each on every visibility tick, and let the
* steps judge different snapshots of the same moment. Null is "no list to
* judge" — before the grant, and on a read that failed.
*/
private val calendars: StateFlow<List<CalendarSource>?> = hasPermission
.flatMapLatest { granted ->
if (granted != true) {
flowOf(null)
} else {
val list: Flow<List<CalendarSource>?> = repository.calendars()
list.catch { emit(null) }.flowOn(io)
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
/**
* Whether the backup step applies: something of yours is worth exporting and
* nothing you write to is synced anywhere. Null while the calendar list
@@ -81,17 +105,90 @@ class OnboardingViewModel @Inject constructor(
* an empty list is also how a null cursor arrives, and guessing false there
* would drop the step for the whole session.
*/
private val backupApplies: Flow<Boolean?> = hasPermission.flatMapLatest { granted ->
if (granted != true) {
flowOf(null)
} else {
repository.calendars()
.map { calendars -> if (calendars.isEmpty()) null else calendars.backupApplies() }
.catch { emit(null) }
.flowOn(io)
}
private val backupApplies: Flow<Boolean?> = calendars.map { list ->
if (list.isNullOrEmpty()) null else list.backupApplies()
}
/** How the step's own create attempt ended, while it is still saying so (#287). */
private val _creation = MutableStateFlow<CalendarCreation?>(null)
val creation: StateFlow<CalendarCreation?> = _creation.asStateFlow()
/**
* Whether the step made a calendar here. Latched for the session and never
* cleared: the new calendar answers [calendarsApplies] with false, which
* would drop the step out of the plan the moment it is used — first from
* under the message that it worked, and then from the step count, which
* would fall by one just as the user pressed Continue.
*/
private val calendarCreatedHere = MutableStateFlow(false)
/**
* Whether the calendar step applies: the device has nothing to show and so
* nothing to put events in. Unlike [backupApplies] an empty list is the
* condition itself, not a failed read — the two look identical here and both
* want the step, so this only resolves once a calendar the app can actually
* show has arrived. Calendars that exist but are all switched off leave it
* unresolved too: the step in front (#287) offers to switch them on, and
* declining that lands here, on the same offer a device with none gets.
*/
private val calendarsApplies: Flow<Boolean?> = combine(
calendars.map { list -> if (list?.any { it.isVisibleInSystem } == true) false else null },
calendarCreatedHere,
) { applies, latched -> if (latched) null else applies }
/**
* Set when the step starts a write of its own, and never cleared: from that
* moment the step holds its place in the plan until it is answered. Set
* *before* the write rather than after it, because a successful one answers
* [hiddenApplies] with false — the step would drop out between the provider
* taking the change and the latch seeing it.
*/
private val hiddenTouched = MutableStateFlow(false)
/** A visibility write the provider did not take, while the step says so (#287). */
private val _hiddenWriteFailed = MutableStateFlow(false)
val hiddenWriteFailed: StateFlow<Boolean> = _hiddenWriteFailed.asStateFlow()
/**
* Whether the device is showing nothing at all right now: calendars exist,
* none is visible, and at least one of them has a switch the step can flip
* (#287). Null only while there is no list to judge — before the grant, and
* on a read that failed — which keeps the step in the plan until the answer
* arrives, the same shrink-only contract [backupApplies] keeps.
*
* An empty list answers false rather than null, unlike [calendarsApplies]:
* a phone with no calendars at all is the next step's business, and this one
* would be listing calendars it cannot see. [hiddenTouched] holds it at null
* from the step's first write onwards.
*/
private val hiddenApplies: Flow<Boolean?> = combine(
calendars.map { list -> list?.allHidden() },
hiddenTouched,
) { hidden, touched -> if (touched) null else hidden }
/** Both calendar steps' inputs: what each applies to, and whether it is answered. */
private val calendarsFlags: Flow<CalendarsFlags> = combine(
calendarsApplies,
prefs.onboardingCalendarsDone,
hiddenApplies,
prefs.onboardingHiddenCalendarsDone,
::CalendarsFlags,
)
/**
* The calendars the hidden-calendars step lists, so the user can judge which
* to switch on rather than take the app's word that there are some. Live: a
* row follows its own switch, and the ones without one stay listed anyway —
* being told a calendar exists is half the point.
*/
val hiddenCalendars: StateFlow<List<CalendarSource>> = calendars
.map { it.orEmpty().orderedForManager() }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyList(),
)
private val flags: Flow<OnboardingFlags> = combine(
prefs.reminderOnboardingDone,
prefs.onboardingWizardArmed,
@@ -110,7 +207,13 @@ class OnboardingViewModel @Inject constructor(
)
val plan: StateFlow<OnboardingPlan?> =
combine(hasPermission, flags, closingFlags, backupApplies) { granted, stored, closing, backup ->
combine(
hasPermission,
flags,
closingFlags,
backupApplies,
calendarsFlags,
) { granted, stored, closing, backup, calendars ->
granted?.let {
onboardingPlan(
hasPermission = it,
@@ -120,6 +223,10 @@ class OnboardingViewModel @Inject constructor(
viewDone = stored.viewDone,
monthStyleDone = stored.monthStyleDone,
backupApplies = backup,
hiddenDone = calendars.hiddenDone,
hiddenApplies = calendars.hiddenApplies,
calendarsDone = calendars.done,
calendarsApplies = calendars.applies,
visibilityArmed = closing.visibilityArmed,
visibilityDone = closing.visibilityDone,
doneShown = closing.doneShown,
@@ -186,6 +293,8 @@ class OnboardingViewModel @Inject constructor(
fun goBack() {
viewModelScope.launch {
when (plan.value?.previous) {
OnboardingStep.HiddenCalendars -> prefs.setOnboardingHiddenCalendarsDone(false)
OnboardingStep.Calendars -> prefs.setOnboardingCalendarsDone(false)
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
@@ -202,6 +311,53 @@ class OnboardingViewModel @Inject constructor(
viewModelScope.launch { prefs.setOnboardingBackupDone() }
}
/**
* Make the device-only calendar the step's editor just described. The step
* stays up on [creation] either way — to confirm the calendar and say where
* to make more, or to say the provider refused it. Answering the step for
* them would leave them where the step exists to stop them being: past it,
* with nowhere to put an event.
*/
fun createLocalCalendar(displayName: String, color: Int, description: String?) {
viewModelScope.launch {
val name = displayName.trim()
_creation.value = runCatching {
repository.createLocalCalendar(
displayName = name,
color = color,
description = description,
)
}.fold(
onSuccess = {
calendarCreatedHere.value = true
CalendarCreation.Created(name)
},
onFailure = { CalendarCreation.Failed },
)
}
}
/** Close the calendar step once its confirmation has been read. */
fun finishCalendars() {
viewModelScope.launch {
prefs.setOnboardingCalendarsDone()
_creation.value = null
}
}
/** Drop the failed attempt so the step offers itself again. */
fun clearCalendarCreation() {
_creation.value = null
}
/**
* Close the calendar step without making one. An empty app is a legitimate
* choice, and so is going off to set up an account and coming back.
*/
fun skipCalendars() {
viewModelScope.launch { prefs.setOnboardingCalendarsDone() }
}
/**
* Turn automatic backup on, writing to the folder the user just picked
* (taking a durable write grant so background runs can keep writing), and
@@ -246,6 +402,52 @@ class OnboardingViewModel @Inject constructor(
viewModelScope.launch { prefs.setOnboardingMonthStyleDone() }
}
/** Switch one listed calendar on or off, from the hidden-calendars step (#287). */
fun setHiddenCalendarVisible(id: Long, visible: Boolean) = visibilityWrite {
repository.setCalendarsVisible(listOf(id), visible)
calendarList().any { it.id == id && it.isVisibleInSystem == visible }
}
/** Switch every hidden calendar back on — the step's one-tap way out (#287). */
fun switchAllCalendarsOn() = visibilityWrite {
val hidden = calendarList().filter { it.hasVisibilitySwitch && !it.isVisibleInSystem }
// Nothing to write is not a write that landed: a list that came back
// empty (a failed read) would otherwise report success and leave the
// step sitting there with no rows and no explanation.
if (hidden.isEmpty()) return@visibilityWrite false
repository.setCalendarsVisible(hidden.map { it.id }, true)
calendarList().any { it.isVisibleInSystem }
}
/**
* Run one of the step's visibility writes, whose lambda answers whether it
* took. Checked rather than assumed: with read-only calendar access the
* choice is kept app-side (#75) and nothing the grid shows would change.
*/
private fun visibilityWrite(landed: suspend () -> Boolean) {
viewModelScope.launch {
hiddenTouched.value = true
_hiddenWriteFailed.value = !runCatching { landed() }.getOrDefault(false)
}
}
private suspend fun calendarList(): List<CalendarSource> = repository.calendars().first()
/** Close the hidden-calendars step, switched on or left as they were (#287). */
fun finishHiddenCalendars() {
viewModelScope.launch {
prefs.setOnboardingHiddenCalendarsDone()
// The error belongs to the attempt, not to the step: stepping back
// into it later must not find a refusal nothing has asked for yet.
_hiddenWriteFailed.value = false
val list = calendarList()
// Switching them all back on answers the one-time notice too (#75) —
// it would otherwise announce, at the end of this same flow, that the
// device is hiding calendars it no longer hides.
if (list.isNotEmpty() && list.all { it.isVisibleInSystem }) retireVisibilityNotice()
}
}
/** Acknowledge the calendar-visibility notice (#75). */
fun finishVisibility() {
viewModelScope.launch {
@@ -295,6 +497,20 @@ private data class OnboardingFlags(
val monthStyleDone: Boolean,
)
/** How the calendar step's create attempt ended (#287). */
sealed interface CalendarCreation {
data class Created(val name: String) : CalendarCreation
data object Failed : CalendarCreation
}
/** Both calendar steps' inputs (#287). */
private data class CalendarsFlags(
val applies: Boolean?,
val done: Boolean,
val hiddenApplies: Boolean?,
val hiddenDone: Boolean,
)
/** The tail of the flow: the visibility notice and the closing screen. */
private data class ClosingFlags(
val visibilityArmed: Boolean,
@@ -309,6 +525,15 @@ data class ViewChoice(
val weekStart: WeekStartPref,
)
/**
* True when this device would show nothing and the step could change that: no
* calendar is visible, and at least one of them has a switch worth flipping —
* a non-syncing calendar holds no events on the device, so switching it on
* leaves the grid exactly as empty (#287).
*/
private fun List<CalendarSource>.allHidden(): Boolean =
any { it.hasVisibilitySwitch } && none { it.isVisibleInSystem }
/**
* True when this device holds events that live nowhere else: at least one local
* calendar worth exporting, and no writable synced calendar to carry them.
@@ -18,11 +18,11 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
import de.jeanlucmakiola.calendula.ui.common.icon
import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.day.DayViewPreview
import de.jeanlucmakiola.calendula.ui.common.label
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
@@ -67,14 +67,19 @@ internal fun ViewStep(
height = PREVIEW_HEIGHT,
)
CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT)
CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT)
CalendarView.MultiDay -> WeekViewPreview(
weekStart = weekStart,
height = PREVIEW_HEIGHT,
dayCount = LocalMultiDayCount.current,
)
CalendarView.Day -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT, dayCount = 1)
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
}
}
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
val isSelected = view == choice.defaultView
GroupedRow(
title = stringResource(view.labelRes),
title = view.label(),
position = positionOf(index, IMPLEMENTED_VIEWS.size),
selected = isSelected,
leading = {
@@ -82,6 +82,7 @@ import de.jeanlucmakiola.calendula.domain.MatchSpan
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchHit
import de.jeanlucmakiola.calendula.domain.SearchMonth
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBack
@@ -125,6 +126,7 @@ fun SearchScreen(
onBack: () -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
visible: Boolean = true,
viewModel: SearchViewModel = hiltViewModel(),
) {
val query by viewModel.query.collectAsStateWithLifecycle()
@@ -183,7 +185,7 @@ fun SearchScreen(
BackHandler(enabled = inSelection) { viewModel.clearSelection() }
Scaffold(
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection),
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection, visible = visible),
containerColor = MaterialTheme.colorScheme.surface,
topBar = {
SearchTopBar(
@@ -508,6 +510,9 @@ private fun SearchResultRow(
onLongClick: (() -> Unit)? = null,
) {
val event = hit.event
// Spans are empty for a blank title — the query never matches the
// placeholder — so marking the substitute is safe.
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
// On a picked row the headline is already recoloured for the secondary
@@ -520,7 +525,7 @@ private fun SearchResultRow(
// Faded like a past event anywhere else in the app — search reaches back
// through the whole history.
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
title = declinedTitle(marked(event.title, hit.titleSpans, highlight), event.isDeclined),
title = declinedTitle(marked(title, hit.titleSpans, highlight), event.isDeclined),
summary = searchSummary(hit, highlight),
position = position,
minHeight = 64.dp,
@@ -76,6 +76,7 @@ internal fun AppearanceScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean = true,
) {
var showTheme by remember { mutableStateOf(false) }
var showBrandFont by remember { mutableStateOf(false) }
@@ -96,6 +97,7 @@ internal fun AppearanceScreen(
title = stringResource(R.string.settings_section_appearance),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
// Theme & colour
GroupedRow(
@@ -41,6 +41,7 @@ internal fun EventFormScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean = true,
) {
var showDefaultDuration by remember { mutableStateOf(false) }
// The calendar whose duration-override picker is open, if any.
@@ -53,6 +54,7 @@ internal fun EventFormScreen(
title = stringResource(R.string.settings_section_event_form),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
SettingsHint(stringResource(R.string.settings_form_fields_hint))
Spacer(Modifier.height(8.dp))
@@ -143,7 +145,7 @@ internal fun EventFormScreen(
durationCalendars.forEachIndexed { index, calendar ->
val override = state.perCalendarEventDuration[calendar.id]
GroupedRow(
title = calendar.displayName,
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
summary = override?.let { durationLabel(it) }
?: stringResource(
R.string.settings_calendar_duration_inherits,
@@ -63,6 +63,7 @@ internal fun NotificationsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit,
onOpenSpecialDates: () -> Unit,
visible: Boolean = true,
) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
@@ -92,6 +93,7 @@ internal fun NotificationsScreen(
title = stringResource(R.string.settings_section_notifications),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
GroupedRow(
title = stringResource(R.string.settings_reminders),
@@ -181,7 +183,7 @@ internal fun NotificationsScreen(
// own section — link there instead.
if (calendar.id in state.managedCalendarIds) {
GroupedRow(
title = calendar.displayName,
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
position = Position.Alone,
leading = { CalendarColorChip(calendar.color) },
@@ -198,7 +200,7 @@ internal fun NotificationsScreen(
}
val expanded = calendar.id in expandedCalendars
GroupedRow(
title = calendar.displayName,
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
position = if (expanded) Position.Top else Position.Alone,
leading = { CalendarColorChip(calendar.color) },
trailing = {
@@ -2,9 +2,7 @@ package de.jeanlucmakiola.calendula.ui.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -21,6 +19,7 @@ import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Cake
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.Language
@@ -54,6 +53,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.licences.LicencesScreen
import de.jeanlucmakiola.floret.components.AboutCard
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
@@ -64,10 +64,13 @@ import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.openIssueTracker
import de.jeanlucmakiola.floret.crash.submitCrashReport
import de.jeanlucmakiola.floret.identity.predictiveBackExit
import de.jeanlucmakiola.floret.locale.AppLanguage
/** The settings sub-screens reached from the hub's category rows. */
private enum class SettingsSection { Appearance, Views, EventForm, Notifications, SpecialDates, Widgets }
private enum class SettingsSection {
Appearance, Views, EventForm, Notifications, SpecialDates, Widgets, Licences
}
/**
* Settings hub: three labelled groups of category rows, each opening a
@@ -81,11 +84,14 @@ fun SettingsScreen(
onManageCalendars: () -> Unit,
onOpenBackup: () -> Unit,
modifier: Modifier = Modifier,
visible: Boolean = true,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
val slideSpec = rememberCalendarSlideSpec()
// Leaves from the predictive-back preview rather than sliding off at full size.
val overlayExit = predictiveBackExit()
Box(
modifier = modifier
@@ -97,54 +103,63 @@ fun SettingsScreen(
onOpenSection = { section = it },
onManageCalendars = onManageCalendars,
onOpenBackup = onOpenBackup,
visible = visible,
)
AnimatedVisibility(
visible = section == SettingsSection.Appearance,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Appearance)
}
AnimatedVisibility(
visible = section == SettingsSection.Views,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null })
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Views)
}
AnimatedVisibility(
visible = section == SettingsSection.EventForm,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null })
EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.EventForm)
}
AnimatedVisibility(
visible = section == SettingsSection.Notifications,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
NotificationsScreen(
state = state,
viewModel = viewModel,
onBack = { section = null },
onOpenSpecialDates = { section = SettingsSection.SpecialDates },
visible = section == SettingsSection.Notifications,
)
}
AnimatedVisibility(
visible = section == SettingsSection.SpecialDates,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null })
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.SpecialDates)
}
AnimatedVisibility(
visible = section == SettingsSection.Widgets,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null })
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets)
}
AnimatedVisibility(
visible = section == SettingsSection.Licences,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
LicencesScreen(onBack = { section = null }, visible = section == SettingsSection.Licences)
}
}
}
@@ -159,8 +174,14 @@ private fun SettingsHub(
onOpenSection: (SettingsSection) -> Unit,
onManageCalendars: () -> Unit,
onOpenBackup: () -> Unit,
visible: Boolean,
) {
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack, predictiveBack = true) {
CollapsingScaffold(
title = stringResource(R.string.settings_title),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
// Card and support row are one grouped block, so the call to action
// continues the container instead of sitting inside it as a button.
Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() }
@@ -253,6 +274,13 @@ private fun SettingsHub(
icon = Icons.Default.Gavel,
position = Position.Middle,
)
GroupedRow(
title = stringResource(R.string.settings_licences),
summary = stringResource(R.string.settings_licences_subtitle),
position = Position.Middle,
leading = { CategoryIcon(Icons.Default.Code, ChipAccent.Neutral) },
onClick = { onOpenSection(SettingsSection.Licences) },
)
AboutLinkRow(
label = stringResource(R.string.settings_about_privacy),
url = stringResource(R.string.about_privacy_url),
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.widget.WidgetSize
@@ -32,8 +33,10 @@ data class SettingsUiState(
val weekStart: WeekStartPref = WeekStartPref.Auto,
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
/** Whether the week/day timeline draws an hour separator line (v2.11). */
val showHourLines: Boolean = false,
/** Whether the week/day timeline seats each hour in its own cell (v2.11). */
val showHourGrid: Boolean = false,
/** Days the multi-day view shows side by side (#193). */
val multiDayCount: Int = SettingsPrefs.DEFAULT_MULTI_DAY_COUNT,
/** How the Agenda screen treats events that already ended today. */
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
/** Whether the month/week grids fade events that have already finished. */
@@ -62,6 +65,7 @@ data class SettingsUiState(
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
/** How tall an hour is drawn in the week and day timelines (#56). */
val timelineScale: TimelineScale = TimelineScale.Regular,
val timelineHours: TimelineHours = TimelineHours(),
/** Order of the views in the navigation drawer (#24); every view is always listed. */
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
/** Optional event-form fields shown by default (rest behind "more fields"). */
@@ -36,6 +36,7 @@ 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.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
@@ -141,29 +142,31 @@ class SettingsViewModel @Inject constructor(
// Display toggles folded into one flow so they fit this group —
// the outer combine is already at its five-arg limit.
combine(
prefs.showHourLines,
prefs.showHourGrid,
prefs.showWeekNumbers,
prefs.agendaShowToday,
prefs.softenCalendarColors,
combine(
prefs.todayButtonInToolbar,
prefs.dragToReschedule,
::Pair,
prefs.multiDayCount,
::Triple,
),
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove) ->
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove, multiDay) ->
DisplayToggles(
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove,
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove, multiDay,
)
},
) { view, screenRange, widgetRange, timeFormat, toggles ->
ViewSettings(
view, screenRange, widgetRange, timeFormat,
showHourLines = toggles.showHourLines,
showHourGrid = toggles.showHourGrid,
showWeekNumbers = toggles.showWeekNumbers,
agendaShowToday = toggles.agendaShowToday,
softenColors = toggles.softenColors,
todayButtonInToolbar = toggles.todayButtonInToolbar,
dragToReschedule = toggles.dragToReschedule,
multiDayCount = toggles.multiDayCount,
)
},
combine(
@@ -178,9 +181,11 @@ class SettingsViewModel @Inject constructor(
prefs.drawerViewOrder,
prefs.monthViewStyle,
prefs.widgetSize,
prefs.timelineScale,
) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale ->
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale)
combine(prefs.timelineScale, prefs.timelineHours, ::Pair),
) { quickSwitch, drawer, monthStyle, widgetSize, (timelineScale, timelineHours) ->
ViewCustomization(
quickSwitch, drawer, monthStyle, widgetSize, timelineScale, timelineHours,
)
},
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
@@ -191,12 +196,13 @@ class SettingsViewModel @Inject constructor(
agendaScreenRange = views.agendaScreenRange,
agendaWidgetRange = views.agendaWidgetRange,
timeFormat = views.timeFormat,
showHourLines = views.showHourLines,
showHourGrid = views.showHourGrid,
showWeekNumbers = views.showWeekNumbers,
agendaShowToday = views.agendaShowToday,
softenColors = views.softenColors,
todayButtonInToolbar = views.todayButtonInToolbar,
dragToReschedule = views.dragToReschedule,
multiDayCount = views.multiDayCount,
agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay,
@@ -206,6 +212,7 @@ class SettingsViewModel @Inject constructor(
monthViewStyle = misc.viewCustomization.monthViewStyle,
widgetSize = misc.viewCustomization.widgetSize,
timelineScale = misc.viewCustomization.timelineScale,
timelineHours = misc.viewCustomization.timelineHours,
allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder,
@@ -292,21 +299,23 @@ class SettingsViewModel @Inject constructor(
val agendaScreenRange: AgendaRange,
val agendaWidgetRange: AgendaRange,
val timeFormat: TimeFormatPref,
val showHourLines: Boolean,
val showHourGrid: Boolean,
val showWeekNumbers: Boolean,
val agendaShowToday: Boolean,
val softenColors: Boolean,
val todayButtonInToolbar: Boolean,
val dragToReschedule: Boolean,
val multiDayCount: Int,
)
private data class DisplayToggles(
val showHourLines: Boolean,
val showHourGrid: Boolean,
val showWeekNumbers: Boolean,
val agendaShowToday: Boolean,
val softenColors: Boolean,
val todayButtonInToolbar: Boolean,
val dragToReschedule: Boolean,
val multiDayCount: Int,
)
private data class MiscSettings(
@@ -323,6 +332,7 @@ class SettingsViewModel @Inject constructor(
val monthViewStyle: MonthViewStyle,
val widgetSize: WidgetSize,
val timelineScale: TimelineScale,
val timelineHours: TimelineHours,
)
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
@@ -532,8 +542,12 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setTimeFormat(pref) }
}
fun setShowHourLines(enabled: Boolean) {
viewModelScope.launch { prefs.setShowHourLines(enabled) }
fun setMultiDayCount(count: Int) {
viewModelScope.launch { prefs.setMultiDayCount(count) }
}
fun setShowHourGrid(enabled: Boolean) {
viewModelScope.launch { prefs.setShowHourGrid(enabled) }
}
fun setShowWeekNumbers(enabled: Boolean) {
@@ -591,15 +605,15 @@ class SettingsViewModel @Inject constructor(
/**
* Enable or disable [view] in the quick-switch cycle, via an atomic
* read-modify-write so a concurrent reorder can't clobber it. The
* MIN_ENABLED floor is re-checked inside the transform, because the screen's
* own guard reads an async-echoed snapshot.
* read-modify-write so a concurrent reorder can't clobber it. Any number of
* views may be turned off; below two the pill hides itself (#150).
*/
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
viewModelScope.launch {
prefs.updateQuickSwitch { config ->
val next = if (enabled) config.enabled + view else config.enabled - view
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
config.copy(
enabled = if (enabled) config.enabled + view else config.enabled - view,
)
}
}
}
@@ -617,6 +631,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setTimelineScale(scale) }
}
fun setTimelineHours(hours: TimelineHours) {
viewModelScope.launch { prefs.setTimelineHours(hours) }
}
fun setDrawerViewOrder(order: List<CalendarView>) {
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
}
@@ -46,6 +46,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
internal fun SpecialDatesScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean = true,
) {
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
val settings by viewModel.state.collectAsStateWithLifecycle()
@@ -76,6 +77,7 @@ internal fun SpecialDatesScreen(
title = stringResource(R.string.settings_section_special_dates),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
// Paused banner: the permission was revoked after enabling.
if (state.enabled && state.stalledPermission) {
@@ -21,10 +21,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
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.resolveFirstDay
@@ -32,12 +34,15 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.icon
import de.jeanlucmakiola.calendula.ui.common.label
import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.month.labelRes
import de.jeanlucmakiola.floret.components.CollapsingScaffold
@@ -55,14 +60,15 @@ import java.time.format.TextStyle as JavaTextStyle
* it belongs to, plus the two cross-view ordering lists (#24, #69).
*
* The quick-switch cycle and the drawer list are independent orders — a view
* off in the cycle is still reachable from the drawer. The cycle needs at least
* [QuickSwitchConfig.MIN_ENABLED] targets.
* off in the cycle is still reachable from the drawer, including when the cycle
* is emptied and the pill disappears (#150).
*/
@Composable
internal fun ViewsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean = true,
) {
var showMonthStyle by remember { mutableStateOf(false) }
var showDefaultView by remember { mutableStateOf(false) }
@@ -71,11 +77,16 @@ internal fun ViewsScreen(
var showPastEvents by remember { mutableStateOf(false) }
var showAgendaScreenRange by remember { mutableStateOf(false) }
var showTimelineScale by remember { mutableStateOf(false) }
var showMultiDayCount by remember { mutableStateOf(false) }
var showHoursStart by remember { mutableStateOf(false) }
var showHoursEnd by remember { mutableStateOf(false) }
val hours = state.timelineHours
CollapsingScaffold(
title = stringResource(R.string.settings_section_views),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
val config = state.quickSwitchConfig
@@ -83,7 +94,7 @@ internal fun ViewsScreen(
SectionHeader(stringResource(R.string.settings_views_all_header))
GroupedRow(
title = stringResource(R.string.settings_default_view),
summary = stringResource(state.defaultView.labelRes),
summary = state.defaultView.label(),
position = Position.Top,
onClick = { showDefaultView = true },
)
@@ -160,22 +171,67 @@ internal fun ViewsScreen(
Spacer(Modifier.height(8.dp))
SectionHeader(stringResource(R.string.settings_week_day_header))
GroupedRow(
title = stringResource(R.string.settings_timeline_scale),
summary = stringResource(state.timelineScale.labelRes),
title = stringResource(R.string.settings_multi_day_count),
summary = multiDayLabel(state.multiDayCount),
position = Position.Top,
onClick = { showTimelineScale = true },
onClick = { showMultiDayCount = true },
)
GroupedRow(
title = stringResource(R.string.settings_timeline_scale),
summary = if (hours.enabled) {
stringResource(R.string.settings_timeline_scale_by_visible_hours)
} else {
stringResource(state.timelineScale.labelRes)
},
position = Position.Middle,
dimmed = hours.enabled,
onClick = if (hours.enabled) null else ({ showTimelineScale = true }),
)
GroupedRow(
title = stringResource(R.string.settings_visible_hours),
summary = if (hours.enabled) {
stringResource(
R.string.settings_visible_hours_on_summary,
hourLabel(hours.startHour),
hourLabel(hours.endHour),
)
} else {
stringResource(R.string.settings_visible_hours_summary)
},
position = Position.Middle,
trailing = {
Switch(
checked = hours.enabled,
onCheckedChange = { viewModel.setTimelineHours(hours.copy(enabled = it)) },
)
},
onClick = { viewModel.setTimelineHours(hours.copy(enabled = !hours.enabled)) },
)
if (hours.enabled) {
GroupedRow(
title = stringResource(R.string.settings_visible_hours_start),
summary = hourLabel(hours.startHour),
position = Position.Middle,
onClick = { showHoursStart = true },
)
GroupedRow(
title = stringResource(R.string.settings_visible_hours_end),
summary = hourLabel(hours.endHour),
position = Position.Middle,
onClick = { showHoursEnd = true },
)
}
GroupedRow(
title = stringResource(R.string.settings_hour_lines),
summary = stringResource(R.string.settings_hour_lines_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.showHourLines,
onCheckedChange = viewModel::setShowHourLines,
checked = state.showHourGrid,
onCheckedChange = viewModel::setShowHourGrid,
)
},
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
onClick = { viewModel.setShowHourGrid(!state.showHourGrid) },
)
Spacer(Modifier.height(8.dp))
@@ -221,8 +277,6 @@ internal fun ViewsScreen(
SectionHeader(stringResource(R.string.settings_quick_switch_header))
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
Spacer(Modifier.height(8.dp))
// Turning a view off is blocked once only the minimum remain enabled.
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
ReorderableColumn(
items = config.order,
keyOf = { it },
@@ -238,8 +292,6 @@ internal fun ViewsScreen(
trailing = {
Switch(
checked = checked,
// Keep the last two on: with fewer, the pill can't switch.
enabled = !checked || canDisable,
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
)
},
@@ -280,7 +332,7 @@ internal fun ViewsScreen(
predictiveBack = true,
options = IMPLEMENTED_VIEWS,
selected = state.defaultView,
label = { stringResource(it.labelRes) },
label = { it.label() },
leading = {
Icon(
imageVector = it.icon,
@@ -342,6 +394,18 @@ internal fun ViewsScreen(
onDismiss = { showPastEvents = false },
)
}
if (showMultiDayCount) {
OptionPicker(
title = stringResource(R.string.settings_multi_day_count),
header = { PickerDescription(stringResource(R.string.settings_multi_day_count_hint)) },
predictiveBack = true,
options = SettingsPrefs.MULTI_DAY_COUNTS.toList(),
selected = state.multiDayCount,
label = { multiDayLabel(it) },
onSelect = viewModel::setMultiDayCount,
onDismiss = { showMultiDayCount = false },
)
}
if (showTimelineScale) {
OptionPicker(
title = stringResource(R.string.settings_timeline_scale),
@@ -361,6 +425,30 @@ internal fun ViewsScreen(
onDismiss = { showTimelineScale = false },
)
}
if (showHoursStart) {
OptionPicker(
title = stringResource(R.string.settings_visible_hours_start),
header = { PickerDescription(stringResource(R.string.settings_visible_hours_start_hint)) },
predictiveBack = true,
options = TimelineHours.startOptions(hours.endHour),
selected = hours.startHour,
label = { hourLabel(it) },
onSelect = { viewModel.setTimelineHours(hours.copy(startHour = it)) },
onDismiss = { showHoursStart = false },
)
}
if (showHoursEnd) {
OptionPicker(
title = stringResource(R.string.settings_visible_hours_end),
header = { PickerDescription(stringResource(R.string.settings_visible_hours_end_hint)) },
predictiveBack = true,
options = TimelineHours.endOptions(hours.startHour),
selected = hours.endHour,
label = { hourLabel(it) },
onSelect = { viewModel.setTimelineHours(hours.copy(endHour = it)) },
onDismiss = { showHoursEnd = false },
)
}
if (showAgendaScreenRange) {
AgendaRangePicker(
title = stringResource(R.string.settings_agenda_range),
@@ -389,7 +477,7 @@ private fun ViewRow(
trailing: @Composable (() -> Unit)? = null,
) {
GroupedRow(
title = stringResource(view.labelRes),
title = view.label(),
position = position,
dimmed = dimmed,
minHeight = ReorderableRowHeight,
@@ -454,3 +542,12 @@ internal fun pastEventDisplayLabelRes(mode: PastEventDisplay): Int = when (mode)
PastEventDisplay.DIM -> R.string.settings_past_events_dim
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
}
@Composable
private fun multiDayLabel(count: Int): String =
pluralStringResource(R.plurals.view_multi_day, count, count)
/** A whole hour in the app's clock convention; 24 reads as the end of the day. */
@Composable
private fun hourLabel(hour: Int): String =
formatMinuteOfDay(hour * 60, LocalUse24HourFormat.current, currentLocale())
@@ -42,6 +42,7 @@ internal fun WidgetsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean = true,
) {
var showAgendaWidgetRange by remember { mutableStateOf(false) }
var showWidgetSize by remember { mutableStateOf(false) }
@@ -50,6 +51,7 @@ internal fun WidgetsScreen(
title = stringResource(R.string.settings_section_widgets),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
SettingsHint(stringResource(R.string.settings_widgets_hint))
Spacer(Modifier.height(8.dp))
@@ -1,10 +1,24 @@
package de.jeanlucmakiola.calendula.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.unit.sp
/**
* Default Material 3 Expressive typography. Custom font + tuned scale will
* land in a later UI-design iteration; the defaults are intentional for V1
* scaffolding to keep the foundation lean.
* Tracking the two label roles the calendar grids are set in. Material gives
* both 0.5sp, tuned for isolated UI labels with room around them; a month chip
* is a text box some 37dp wide, where 0.5sp on an 11sp glyph spends most of a
* character on spacing alone. 0.1sp is what Material itself sets labelLarge to,
* so the label family stays coherent (#190).
*/
val CalendulaTypography = Typography()
private val LabelTracking = 0.1.sp
/**
* Material 3 Expressive typography with the label roles' tracking tightened.
* Everything else is the default scale.
*/
val CalendulaTypography: Typography = Typography().let { base ->
base.copy(
labelMedium = base.labelMedium.copy(letterSpacing = LabelTracking),
labelSmall = base.labelSmall.copy(letterSpacing = LabelTracking),
)
}
@@ -0,0 +1,169 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
import kotlin.math.roundToInt
/** A day column's place across the timeline, in pixels from its start edge. */
@Immutable
data class ColumnSlot(val left: Float, val width: Float) {
val right: Float get() = left + width
}
/**
* [date]'s column on a page showing this span across [width] pixels, with [gap]
* between columns. A date off the page carries on at the same pitch, off screen
* on its side — where a view switch slides it in from, or out to.
*/
fun DaySpan.slot(date: LocalDate, width: Float, gap: Float): ColumnSlot {
val pitch = (width + gap) / count
return ColumnSlot(left = start.daysUntil(date) * pitch, width = pitch - gap)
}
/** The last day of the span. */
val DaySpan.end: LocalDate get() = start.plus(count - 1, DateTimeUnit.DAY)
/**
* The timeline's columns: one page's, or — while switching between the day, the
* multi-day and the week view (#184) — every day of both pages, each column
* between where it sits in the one and where it sits in the other.
*
* @param progress how far from [from] to [to], read at layout time only.
*/
@Stable
class ColumnGeometry(val from: DaySpan, val to: DaySpan, val progress: () -> Float) {
/** Every day either page shows, in order: the two overlap, so they run on unbroken. */
val dates: List<LocalDate> = run {
val first = minOf(from.start, to.start)
val last = maxOf(from.end, to.end)
(0..first.daysUntil(last)).map { first.plus(it, DateTimeUnit.DAY) }
}
fun slot(date: LocalDate, width: Float, gap: Float): ColumnSlot {
val a = from.slot(date, width, gap)
if (from == to) return a
val b = to.slot(date, width, gap)
val p = progress()
return ColumnSlot(lerp(a.left, b.left, p), lerp(a.width, b.width, p))
}
/** The span of columns [range] covers, between its extent on each page. */
fun extent(fromRange: ClosedRange<LocalDate>, toRange: ClosedRange<LocalDate>, width: Float, gap: Float): ColumnSlot {
val a = from.extent(fromRange, width, gap)
if (from == to && fromRange == toRange) return a
val b = to.extent(toRange, width, gap)
val p = progress()
val left = lerp(a.left, b.left, p)
return ColumnSlot(left, lerp(a.right, b.right, p) - left)
}
companion object {
/** One page's columns, standing still. */
fun of(span: DaySpan): ColumnGeometry = ColumnGeometry(span, span) { 0f }
}
}
private fun DaySpan.extent(range: ClosedRange<LocalDate>, width: Float, gap: Float): ColumnSlot {
val left = slot(range.start, width, gap).left
return ColumnSlot(left, slot(range.endInclusive, width, gap).right - left)
}
internal fun lerp(a: Float, b: Float, p: Float): Float = a + (b - a) * p
/**
* Lays [content] out once per day of [geometry], each across its column. The
* columns are placed in the layout pass, so a view switch moving them every
* frame re-lays them out without recomposing them. Children fill the height
* the layout is given, or wrap it when it has none.
*/
@Composable
fun TimelineColumns(
geometry: ColumnGeometry,
gap: Dp,
modifier: Modifier = Modifier,
content: @Composable (LocalDate) -> Unit,
) {
val dates = geometry.dates
Layout(
content = {
dates.forEach { date ->
key(date) { Box(propagateMinConstraints = true) { content(date) } }
}
},
modifier = modifier,
) { measurables, constraints ->
val width = constraints.maxWidth
val gapPx = gap.toPx()
val slots = dates.map { geometry.slot(it, width.toFloat(), gapPx) }
val placeables = measurables.mapIndexed { i, measurable ->
val left = slots[i].left.roundToInt()
val columnWidth = (slots[i].right.roundToInt() - left).coerceAtLeast(0)
measurable.measure(constraints.copy(minWidth = columnWidth, maxWidth = columnWidth))
}
val height = placeables.maxOfOrNull { it.height }?.coerceAtLeast(constraints.minHeight)
?: constraints.minHeight
layout(width, height) {
placeables.forEachIndexed { i, placeable ->
placeable.placeRelative(slots[i].left.roundToInt(), 0)
}
}
}
}
/**
* An all-day bar as each page lays it out: the days it covers there, clipped to
* the page, and its lane. A bar only one page has keeps its days on the other,
* so it slides out with them rather than vanishing.
*/
@Immutable
data class PlacedBar(
val event: EventInstance,
/** The day the bar is drawn from on the page it is tagged on, for the view morph. */
val keyDate: LocalDate,
val fromRange: ClosedRange<LocalDate>,
val toRange: ClosedRange<LocalDate>,
val fromLane: Int,
val toLane: Int,
)
/** [state]'s all-day bars, standing still. */
internal fun staticBars(state: WeekUiState.Success): List<PlacedBar> =
state.allDaySpans.map { span ->
val range = state.days[span.startCol]..state.days[span.endCol]
PlacedBar(span.event, range.start, range, range, span.lane, span.lane)
}
/**
* The all-day bars of both pages of a view switch, each once: matched by
* occurrence, placed on each page as that page lays it out.
*/
internal fun morphBars(from: WeekUiState.Success?, to: WeekUiState.Success?): List<PlacedBar> {
val fromBars = from?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() }
val toBars = to?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() }
return (fromBars.keys + toBars.keys).map { occurrence ->
val a = fromBars[occurrence]
val b = toBars[occurrence]
PlacedBar(
event = (a ?: b)!!.event,
keyDate = (a ?: b)!!.keyDate,
fromRange = (a ?: b)!!.fromRange,
toRange = (b ?: a)!!.toRange,
fromLane = (a ?: b)!!.fromLane,
toLane = (b ?: a)!!.toLane,
)
}
}
private fun EventInstance.occurrence(): Pair<Long, Long> = eventId to start.toEpochMilliseconds()
@@ -0,0 +1,32 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.animation.core.Animatable
import androidx.compose.runtime.Stable
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.LocalDate
/**
* A switch between two timeline views in progress (#184): the page on screen
* when it started, the page it ends on, and how far between the two it is.
*/
@Stable
internal class TimelineMorph(
val fromKind: TimelineKind,
val toKind: TimelineKind,
val from: DaySpan,
val to: DaySpan,
val fromState: StateFlow<WeekUiState>,
val toState: StateFlow<WeekUiState>,
) {
val progress = Animatable(0f)
/** Whether the columns change size a lot, from a single day to a week or back, and take longer for it. */
val isLong: Boolean get() = maxOf(from.count, to.count) > LONG_SWITCH_RATIO * minOf(from.count, to.count)
/** Where each day's events come from: the page it was on, else the page it goes to. */
fun sourceOf(date: LocalDate): Side = if (date in from.start..from.end) Side.From else Side.To
enum class Side { From, To }
}
private const val LONG_SWITCH_RATIO = 3
@@ -0,0 +1,107 @@
package de.jeanlucmakiola.calendula.ui.week
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
/** Pages the week pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
internal const val WEEK_PAGE_COUNT: Int = 20_000
private const val EPOCH_PAGE = WEEK_PAGE_COUNT / 2
/** The week at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
private val PAGE_EPOCH = LocalDate(2000, 1, 3)
/** First day of the week shown on pager [page], with weeks starting on [firstDay]. */
internal fun weekStartForPage(page: Int, firstDay: DayOfWeek): LocalDate =
PAGE_EPOCH.startOfWeek(firstDay).plus((page - EPOCH_PAGE) * 7, DateTimeUnit.DAY)
/** The pager page whose week contains [date], with weeks starting on [firstDay]. */
internal fun weekPageFor(date: LocalDate, firstDay: DayOfWeek): Int {
val days = PAGE_EPOCH.startOfWeek(firstDay).daysUntil(date.startOfWeek(firstDay))
return (EPOCH_PAGE + days / 7).coerceIn(0, WEEK_PAGE_COUNT - 1)
}
/** How a timeline pager cuts the calendar into pages of [dayCount] days. */
sealed interface PageGrid {
val dayCount: Int
val pageCount: Int
/** First day shown on pager [page]. */
fun startForPage(page: Int): LocalDate
/** The pager page showing [date]. */
fun pageFor(date: LocalDate): Int
}
/** Whole weeks, starting on [firstDay]. */
data class WeekGrid(val firstDay: DayOfWeek) : PageGrid {
override val dayCount: Int get() = 7
override val pageCount: Int get() = WEEK_PAGE_COUNT
override fun startForPage(page: Int): LocalDate = weekStartForPage(page, firstDay)
override fun pageFor(date: LocalDate): Int = weekPageFor(date, firstDay)
}
/** Pages the multi-day pager spans — over three centuries at the widest page. */
internal const val DAYS_PAGE_COUNT: Int = 40_000
private const val DAYS_EPOCH_PAGE = DAYS_PAGE_COUNT / 2
/** A fixed day the multi-day pages are counted from. */
private val DAYS_EPOCH = LocalDate(2000, 1, 1)
/**
* Runs of [dayCount] days (#193), offset [phase] days from a fixed epoch so any
* date can start a page. The phase stays put while paging and only changes when
* a jump moves another date to the first column.
*/
data class DaysGrid(override val dayCount: Int, val phase: Int) : PageGrid {
override val pageCount: Int get() = DAYS_PAGE_COUNT
override fun startForPage(page: Int): LocalDate =
DAYS_EPOCH.plus(phase + (page - DAYS_EPOCH_PAGE) * dayCount, DateTimeUnit.DAY)
override fun pageFor(date: LocalDate): Int =
(DAYS_EPOCH_PAGE + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount))
.coerceIn(0, DAYS_PAGE_COUNT - 1)
companion object {
/** The grid of [dayCount]-day pages on which [date] is a page's first day. */
fun startingAt(date: LocalDate, dayCount: Int): DaysGrid =
DaysGrid(dayCount, Math.floorMod(DAYS_EPOCH.daysUntil(date), dayCount))
}
}
/** Pages the day pager spans — a little under four centuries, centred on [DAY_EPOCH]. */
internal const val DAY_PAGE_COUNT: Int = 140_000
private const val DAY_EPOCH_PAGE = DAY_PAGE_COUNT / 2
/** The day at [DAY_EPOCH_PAGE]; any date works, it only has to stay fixed. */
private val DAY_EPOCH = LocalDate(2000, 1, 1)
/** Single days: every date starts a page of its own. */
data object DayGrid : PageGrid {
override val dayCount: Int get() = 1
override val pageCount: Int get() = DAY_PAGE_COUNT
override fun startForPage(page: Int): LocalDate =
DAY_EPOCH.plus(page - DAY_EPOCH_PAGE, DateTimeUnit.DAY)
override fun pageFor(date: LocalDate): Int =
(DAY_EPOCH_PAGE + DAY_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
}
/**
* Where the focused date goes when the pager settles on [page] (#184): kept if
* the page still shows it, otherwise moved by the pages travelled, so it keeps
* its place in the page — the same weekday a week on.
*/
internal fun PageGrid.focusOnPage(focus: LocalDate, page: Int): LocalDate {
val start = startForPage(page)
val end = start.plus(dayCount - 1, DateTimeUnit.DAY)
if (focus in start..end) return focus
return focus.plus((page - pageFor(focus)) * dayCount, DateTimeUnit.DAY).coerceIn(start, end)
}

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