Compare commits

..
Author SHA1 Message Date
makiolaj dce83005ea Release v2.23.0-beta.2 (#141)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 8m15s
2026-10-09 22:23:14 +02:00
Jean-Luc Makiola 1ac486cc4b Select, move and resize timeline events with a long press (#141) (#384)
Beta — Codeberg pre-release / detect (push) Successful in 12s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-09 22:23:06 +02:00
makiolaj 549ef1dfbf Release v2.23.0-beta.1 (#367)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 9m45s
2026-10-09 10:25:48 +02:00
Jean-Luc Makiolaandmakiolaj 164b5fb25d Scroll back into past days in the agenda (#367) (#382)
Beta — Codeberg pre-release / detect (push) Successful in 4s
Beta — Codeberg pre-release / beta (push) Skipped
The agenda opens on today, and scrolling up loads earlier days 30 at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today.

The agenda no longer picks up the day the month or day view was on, so "Next 30 days" always counts from today. That fixes the bug from the issue comment. It also means the agenda no longer follows the shared view date from #184 when you switch to it. Backing out of a day opened from the agenda keeps your scroll position.

Not in the issue: multi-day events that are still running now dim on the days already behind you, in the agenda, month, week and day views. Before, they stayed at full strength until the whole event ended. A spanning bar dims once the last day it covers in that row has passed.

The widget is unchanged and still starts at today.

Closes #367

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/382
2026-10-09 10:25:25 +02:00
Jean-Luc Makiolaandmakiolaj 6afcfd49f1 Release v2.22.5 (#381)
Renovate / renovate (push) Successful in 1m13s
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 19m27s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m2s
When you reopened the app the next morning, the month view still marked yesterday as today and still had the day you picked the evening before selected.

- New `TodaySource` gives the current date. It rechecks once a minute while a calendar is visible, and also reacts to the system's date, time and time-zone broadcasts. Month, Week/Day/3-day, Agenda and the shared date that every view opens on all use it.
- On a day change, a view still showing the old today moves to the new one, and Split clears the previous day's selection. A view you navigated elsewhere stays where it is.
- Month and Week take today from the live value instead of their cached page data.
- The issue only mentions the month view. The same stale today also affected Week, Day, Agenda and switching between views, so all of them are fixed.
- versionName 2.22.5 / versionCode 2220599, CHANGELOG, What's New in all 17 locales.

Closes #373

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/381
2026-10-07 20:22:50 +02:00
Jean-Luc Makiolaandmakiolaj 5f77cac7ea Move to floret-kit 0.7.0 (#375)
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
Renovate / renovate (push) Successful in 1m11s
Repoints the floret-kit submodule at the convergence merge (jlmakiola/floret-kit#11), which folds the clockula/vouch/glance branches back into floret-kit main so all four apps share one line again.

floret-kit's dependency bump requires Gradle >= 9.6 in the consuming build, so this also bumps Gradle 9.5.1 → 9.8.0, AGP 9.2.1 → 9.4.1 and Kotlin 2.3.21 → 2.4.20. No source changes were needed.

**Merge jlmakiola/floret-kit#11 first** so the pinned commit is on floret-kit main.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/375
2026-10-07 09:56:00 +02:00
Jean-Luc Makiolaandmakiolaj 20a6548ac8 Release v2.22.4 (#374)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 9s
Renovate / renovate (push) Successful in 1m57s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 18m48s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m19s
Promotes 2.22.4-beta.1 to stable. The app code is the same as the beta.

- versionName 2.22.4, versionCode 2220499 (new scheme)
- CHANGELOG.md: the Unreleased entries move under [2.22.4]
- What's New `changelogs/2220499.txt` in all 17 store locales

Includes #370 (re-query after own writes and on return to foreground) and #371 (widgets load only what they show).

Closes #363
Closes #364

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/374
2026-10-07 09:05:32 +02:00
Jean-Luc Makiolaandmakiolaj 9c35712573 ci(release): beta releases as Codeberg-only pre-releases (#369)
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
Renovate / renovate (push) Successful in 1m30s
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
### What this changes

Adds beta releases, ported from Agendula (#38 and #40 there). Pushing a `release/*` branch whose `versionName` is `X.Y.Z-beta.N` runs the new `.gitea/workflows/beta.yaml`: unit tests, build + sign with the app key, then a **Codeberg pre-release** (APK + `.sha256`) and a Gitea pre-release (R8 mapping). F-Droid (self-hosted and official) and Play never get a beta; Obtainium only offers it with *Include prereleases* on.

- **New versionCode scheme**, derived in one place by `scripts/version_info.sh`. 2.22.3 is the last legacy version (`X*10000 + Y*100 + Z`); from **2.22.4** on it is `X*1000000 + Y*10000 + Z*100 + N` for a beta (N = 1–98) and `+ 99` for stable, so `2.22.4` → `2220499`, `2.23.0-beta.1` → `2230001`.
- **`scripts/release_gate.sh`** decides in both `detect` jobs whether the version still needs publishing. Tags are read by exact name via `git ls-remote`: Codeberg's `git/refs/tags/<name>` matches by prefix, so a beta tag would otherwise hide its stable release. A beta counts as done only once its Codeberg pre-release carries the APK (a failed publish is redone by the next push) and must be newer than the latest stable.
- **Shared scripts** `publish_codeberg_release.sh`, `publish_gitea_release.sh`, `release_notes.sh`, `write_keystore.sh`, and a local composite action `.gitea/actions/android-env` for the toolchain setup, used by both `release.yaml` and `beta.yaml`. The stable path behaves as before (Codeberg step stays best-effort).
- **Guards:** CI fails a PR whose `versionCode` doesn't match its `versionName`, or that brings a beta into `main`; `release.yaml` refuses a beta as a backstop; betas get no store What's New (`sync_changelog_to_fastlane.sh`, `check_changelog_lengths.sh`).
- **Docs:** versionCode table and "Cutting a beta" in `docs/RELEASING.md`, the Obtainium note in the README, `build.gradle.kts` comment.
- `gradle/gradle-daemon-jvm.properties` now points at JetBrains' own JBR 21.0.11 downloads instead of foojay, which dropped JetBrains 21 from its index (the pinned ids return 400, so a clean runner can't provision the daemon JVM).

### Why

To ship test builds of an upcoming version to opted-in testers before the stable release, without them reaching F-Droid or Play users.

Infra-only, so this targets `main` directly; no version bump. When cutting 2.22.4, its What's New file is `changelogs/2220499.txt`.

### Checklist

- [x] Targeting `main` (infra change, noted above)
- [x] No `values-*/strings.xml` touched
- [x] `CHANGELOG.md` not updated: release infrastructure, not a user-visible change
- [x] No planning or design documents committed

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/369
2026-10-06 18:43:45 +02:00
renovate-bot 4342c6ab6d fix(deps): update composebom to v2026.09.00 (#238)
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
Renovate / renovate (push) Successful in 1m6s
This PR contains the following updates:

| Package | Type | Change | Age | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|---|
| [androidx.compose:compose-bom](https://developer.android.com/jetpack) | dependencies | `2026.06.01` → `2026.09.00` | 25 d | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose:compose-bom/2026.06.01/2026.09.00?slim=true) |

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 04:00 AM and 06:59 AM, only on Monday (`* 4-6 * * 1`)
- Automerge
  - At any time (no schedule defined)

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

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

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

---

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

---

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

Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/238
2026-10-05 13:40:00 +02:00
renovate-bot 57bad5c74c fix(deps): update material3 (alpha) to v1.5.0-alpha29 (#274)
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
Renovate / renovate (push) Canceled after 0s
This PR contains the following updates:

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

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 04:00 AM and 06:59 AM, only on Monday (`* 4-6 * * 1`)
- Automerge
  - At any time (no schedule defined)

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

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

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

---

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

---

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

Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/274
2026-10-05 13:39:41 +02:00
renovate-bot ebf833d0bd fix(deps): update glance to v1.2.0 (#275)
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
Renovate / renovate (push) Successful in 1m10s
This PR contains the following updates:

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

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 04:00 AM and 06:59 AM, only on Monday (`* 4-6 * * 1`)
- Automerge
  - At any time (no schedule defined)

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

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

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

---

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

---

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

Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/275
2026-10-05 13:39:03 +02:00
renovate-bot 2f24a9878c fix(deps): update lifecyclecompose to v2.11.0 (#277)
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
Renovate / renovate (push) Successful in 1m5s
This PR contains the following updates:

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

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 04:00 AM and 06:59 AM, only on Monday (`* 4-6 * * 1`)
- Automerge
  - At any time (no schedule defined)

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

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

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

---

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

---

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

Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/277
2026-10-05 13:37:03 +02:00
Jean-Luc Makiolaandmakiolaj 2313671965 chore(renovate): rebase conflicted PRs on every merge to main (#366)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 30s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m29s
Renovate only rebases while it runs, and it ran once a week, so merging one dependency PR left the others conflicted until the next Monday.

- `renovate.yml`: also run on push to `main` (mirror syncs fire push events, like `release.yaml`).
- `renovate.json5`: new PRs only in a Monday window (`* 4-6 * * 1`) around the weekly cron; on-merge runs only rebase conflicted PRs (`rebaseWhen: "conflicted"`).

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/366
2026-10-05 13:33:59 +02:00
Jean-Luc Makiola e1b80d9f89 Release v2.22.3 (#362)
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 14m42s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m40s
Patch release for #361. No app changes.

- `title.txt` in every store locale is now "Calendula: Calendar", with "Calendar" taken from the app's own translation of the word (`event_detail_calendar`). Since 2.22.0 the Play listing is pushed from the repo, and the repo title was only "Calendula", which replaced the suffixed name from the Play Console.
- ja has no translation for that string yet, so it uses カレンダー. zh-CN has "Calendula" as its translation of "Calendar" on Weblate, so the title uses 日历 instead. That string should be fixed on Weblate.
- F-Droid reads the same `title.txt`, so the F-Droid listing gets the suffix too.

Plus the release commit: CHANGELOG section, versionName 2.22.3 / versionCode 22203, and the 22203 changelogs for every store locale. Merging this to main triggers the release pipeline.

Closes #361
2026-10-02 22:14:34 +02:00
Jean-Luc Makiola 99bb6adf80 Release v2.22.2 (#360)
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 14m8s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 2m59s
Patch release for #359. No app changes.

- The Dutch store listing moved from `fastlane/metadata/android/nl` to `nl-NL`. Play rejects plain `nl`, so the Play upload of 2.22.0 and 2.22.1 failed with `nl - Invalid request`. F-Droid accepts either code.
- `check_store_listing.py` now checks every store code in `store-locales.txt` against the languages Play accepts, so CI catches this before a release.
- The 22202 "What's New" covers the 2.22 highlights, since Play users go straight from 2.21.0 to 2.22.2, and says the app is the same as 2.22.1.

Closes #359
2026-10-02 20:41:17 +02:00
Jean-Luc Makiola 06ee010f07 Release v2.22.1 (#358)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 10s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 14m6s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 1m28s
Patch release for #357.

- Tapping a meeting link in an event's location opened it in a maps app. Links in the location are now underlined and open in the browser, the same way description links do. Tapping the rest of the location still opens maps, so a location with both an address and a link works for both.
- Link detection moved into `WebLinks.kt` with unit tests. It now matches a capitalised scheme (`Https://`), lower-cases the scheme before launching, and leaves `<…>` out of the URL (the Outlook/Teams style). This applies to description links too.
- Links open with `CATEGORY_BROWSABLE` through one helper. A failed launch shows a "No app can open this link" toast instead of doing nothing or crashing on a `SecurityException`.
- The location's maps tap has an "Open in maps" accessibility label (new `onTapLabel` on `copyOnLongPress`).

The issue only asked for links to open in the browser. Inline links instead of switching the whole tap target came out of review: they keep the address reachable when a location holds both.

Plus the release commit: CHANGELOG section, versionName 2.22.1 / versionCode 22201, and the 22201 changelogs for every store locale. Merging this to main triggers the release pipeline.

Closes #357
2026-10-02 18:45:38 +02:00
Jean-Luc Makiolaandmakiolaj 5f07adef2f Release v2.22.0 (#355)
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 14m16s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 1m26s
Views: navigation, transitions and split-view behaviour.

See CHANGELOG.md for the full list.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/355
2026-10-01 16:21:11 +02:00
Jean-Luc Makiola bfc05795f0 Translations update from Weblate (#333)
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
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/333
2026-10-01 14:39:52 +02:00
N Donkersloot 9de5f3f00d Translated using Weblate (Dutch)
Currently translated at 100.0% (633 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/nl/
2026-09-30 11:00:32 +00:00
Weblate bfdbd49aa1 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-09-30 06:10:49 +00:00
N Donkersloot 844a3c22ff Added translation using Weblate (Dutch) 2026-09-30 06:10:49 +00:00
Sup99999 87a08a4d24 Translated using Weblate (Arabic)
Currently translated at 100.0% (633 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-30 06:10:48 +00:00
Sup99999 dc0637c37c Translated using Weblate (Arabic)
Currently translated at 100.0% (633 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-29 18:29:12 +00:00
Weblate 381eca6660 Translated using Weblate (Czech)
Currently translated at 100.0% (633 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-09-29 07:07:16 +00:00
James Trivette 36018b3e52 Translated using Weblate (Czech)
Currently translated at 100.0% (633 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-09-29 07:07:16 +00:00
Paulo Gomez 3c0324c071 Translated using Weblate (Portuguese (Brazil))
Currently translated at 90.2% (571 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pt_BR/
2026-09-29 07:07:15 +00:00
Dmitri Gabinski a70108dff8 Translated using Weblate (Belarusian)
Currently translated at 89.7% (568 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/be/
2026-09-28 16:31:37 +00:00
makiolaj 83447ab68a Translated using Weblate (German)
Currently translated at 99.6% (631 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-09-28 16:31:36 +00:00
Sup99999 4ac937c54f Translated using Weblate (Arabic)
Currently translated at 99.8% (632 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-25 18:12:14 +00:00
Sup99999 b5f28929d7 Translated using Weblate (Arabic)
Currently translated at 99.8% (632 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-25 02:08:49 +00:00
Weblate d729a928ea Translated using Weblate (Arabic)
Currently translated at 99.8% (632 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-25 01:03:55 +00:00
Sup99999 01a4ad742b Translated using Weblate (Arabic)
Currently translated at 99.8% (632 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-25 01:03:55 +00:00
Weblate 6f77a5e12e Translated using Weblate (Arabic)
Currently translated at 97.4% (617 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-25 00:31:23 +00:00
Sup99999 e29c38d88c Translated using Weblate (Arabic)
Currently translated at 97.4% (617 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-25 00:31:23 +00:00
Weblate 04f509fc4c Translated using Weblate (Arabic)
Currently translated at 96.8% (613 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-25 00:24:30 +00:00
Sup99999 b6061f1384 Translated using Weblate (Arabic)
Currently translated at 96.8% (613 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-25 00:24:30 +00:00
Weblate 38c3a82b54 Translated using Weblate (Arabic)
Currently translated at 96.3% (610 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-25 00:13:36 +00:00
Sup99999 d3f1409503 Translated using Weblate (Arabic)
Currently translated at 96.3% (610 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-25 00:13:36 +00:00
Sup99999 3475a073ad Translated using Weblate (Arabic)
Currently translated at 94.9% (601 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-24 23:59:17 +00:00
Sup99999 643c61db65 Translated using Weblate (Arabic)
Currently translated at 94.7% (600 of 633 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-24 23:55:39 +00:00
James Trivette 1734b7863c 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-24 19:35:26 +00:00
Louis Leblanc d45d4f443b Translated using Weblate (French)
Currently translated at 100.0% (590 of 590 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/fr/
2026-09-24 19:35:26 +00:00
378 changed files with 11102 additions and 3915 deletions
+2 -2
View File
@@ -21,9 +21,9 @@ labels:
- Calendula version: <!-- Settings → bottom of the screen --> - Calendula version: <!-- Settings → bottom of the screen -->
- Android version: - Android version:
- Device: - Device:
- Installed from: <!-- official F-Droid / the self-hosted repo / built from source --> - Installed from: <!-- official F-Droid / the self-hosted repo / Google Play / Codeberg APK or Obtainium / built from source -->
- Affected calendar: <!-- Google, CalDAV (DAVx5, Nextcloud, …), on-device/local, - Affected calendar: <!-- Google, CalDAV (DAVx5, Nextcloud, …), on-device/local,
subscribed/WebCal, birthdays — provider behaviour differs subscribed/WebCal, birthdays. Provider behaviour differs
a lot per account type, so this often points straight at a lot per account type, so this often points straight at
the cause --> the cause -->
- Time zone: <!-- only if the problem involves dates or all-day events --> - Time zone: <!-- only if the problem involves dates or all-day events -->
+2 -2
View File
@@ -6,8 +6,8 @@ contact_links:
- name: Translate Calendula - name: Translate Calendula
url: https://weblate.dev.jeanlucmakiola.de/engage/calendula/ url: https://weblate.dev.jeanlucmakiola.de/engage/calendula/
about: >- about: >-
Translations are managed on Weblate, not here — it owns every values-* Translations are managed on Weblate. It owns every values-* file, so a
file, so a hand-edited translation gets overwritten on the next sync. hand-edited translation gets overwritten on the next sync.
No coding needed: pick or request a language and translate in the browser. No coding needed: pick or request a language and translate in the browser.
- name: Contributing guide - name: Contributing guide
+3 -3
View File
@@ -1,6 +1,6 @@
--- ---
name: Crash report name: Crash report
about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form. about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash). It copies the report to your clipboard and prefills this form.
title: "Crash: " title: "Crash: "
labels: labels:
- bug - bug
@@ -11,10 +11,10 @@ labels:
<!-- <!--
Thanks for reporting a crash in Calendula! Thanks for reporting a crash in Calendula!
If the app prefilled this for you, the crash report is already below — just add If the app prefilled this for you, the crash report is already below. Add
what you were doing and submit. Otherwise, paste the report from your clipboard what you were doing and submit. Otherwise, paste the report from your clipboard
into the code block. The report contains only app/Android/device versions and the into the code block. The report contains only app/Android/device versions and the
stack trace — no personal data or calendar content. stack trace, with no personal data or calendar content.
--> -->
### What happened ### What happened
+1 -1
View File
@@ -9,7 +9,7 @@ labels:
### What would you like Calendula to do? ### What would you like Calendula to do?
### Why — what problem does it solve? ### What problem does it solve?
### Anything else ### Anything else
+3 -3
View File
@@ -5,7 +5,7 @@ Please skim CONTRIBUTING.md if you haven't:
https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
Two things it's easy to get wrong: Two things it's easy to get wrong:
• Features need a discussed issue first — an undiscussed feature PR may be • Features need a discussed issue first. An undiscussed feature PR may be
closed unmerged even when the code is good. closed unmerged even when the code is good.
• Target the release branch for your issue's milestone (milestone 2.18.0 → • Target the release branch for your issue's milestone (milestone 2.18.0 →
release/v2.18.0), not main. If you targeted main, just say so below and it release/v2.18.0), not main. If you targeted main, just say so below and it
@@ -17,7 +17,7 @@ Two things it's easy to get wrong:
### Why ### Why
<!-- Closes #123 — link the issue this implements or fixes. --> <!-- Closes #123: link the issue this implements or fixes. -->
### How it was tested ### How it was tested
@@ -34,7 +34,7 @@ are especially useful for UI changes.
### Checklist ### Checklist
- [ ] There's an issue for this, and (for a feature) it got a go-ahead - [ ] There's an issue for this, and (for a feature) it got a go-ahead
- [ ] Targeting the release branch for that issue's milestone — or `main`, noted above - [ ] Targeting the release branch for that issue's milestone (or `main`, noted above)
- [ ] `./gradlew lint test assembleDebug` passes locally - [ ] `./gradlew lint test assembleDebug` passes locally
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine) - [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible - [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible
+29 -1
View File
@@ -37,12 +37,40 @@ jobs:
- name: Reproducible-release invariant - name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh run: bash scripts/check_reproducible_release.sh
# versionCode must match versionName (the official F-Droid repo builds the
# tag as committed), and a beta must never reach main.
- name: Committed version is well-formed
env:
BASE: ${{ github.base_ref }}
run: |
set -e
bash scripts/version_info.sh --check
if [ "${BASE#refs/heads/}" = "main" ] && [ "$(bash scripts/version_info.sh channel)" = "beta" ]; then
echo "ERROR: versionName $(bash scripts/version_info.sh version) is a beta." >&2
echo "Set the stable version (and its versionCode) before merging into main." >&2
exit 1
fi
# Play rejects a "What's New" over 500 characters, which would fail the # Play rejects a "What's New" over 500 characters, which would fail the
# upload after the release had already shipped everywhere else. Cheap, so # upload after the release had already shipped everywhere else. Cheap, so
# it runs on every PR rather than only on the release merge. # it runs on every PR rather than only on the release merge. A beta ships
# no What's New, so only the older files are checked for one.
- name: Changelog length invariant - name: Changelog length invariant
run: bash scripts/check_changelog_lengths.sh run: bash scripts/check_changelog_lengths.sh
# The whole listing goes to Play with every release; a field over its cap
# or an image Play rejects would fail that upload.
- name: Store listing invariant
run: |
if ! command -v python3 >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
apt-get update && apt-get install -y python3
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache python3
fi
fi
python3 scripts/check_store_listing.py
# Decide whether anything that affects the app build changed. Docs, store # Decide whether anything that affects the app build changed. Docs, store
# metadata, licence texts and forge housekeeping don't, so those PRs skip # metadata, licence texts and forge housekeeping don't, so those PRs skip
# the SDK + Gradle work below but still report a green `ci`. # the SDK + Gradle work below but still report a green `ci`.
+57
View File
@@ -0,0 +1,57 @@
name: Android build environment
description: JDK 17, Android SDK + build tools, Gradle cache and jq for the release and beta pipelines.
runs:
using: composite
steps:
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: ''
- name: Setup Android SDK cache
uses: actions/cache@v4
with:
path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages
shell: bash
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
"platform-tools" \
"platforms;android-37.0" \
"build-tools;36.0.0"
- name: Setup Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Install jq
shell: bash
run: |
set -e
SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update
$SUDO apt-get install -y jq
elif command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache jq
fi
- name: Grant execute permission for gradlew
shell: bash
run: chmod +x ./gradlew
+97
View File
@@ -0,0 +1,97 @@
name: Beta — Codeberg pre-release
# Pushing a release/* branch whose versionName is X.Y.Z-beta.N publishes a
# Codeberg pre-release (APK + SHA-256) and a Gitea pre-release (R8 mapping).
# Betas never reach F-Droid or Play. See docs/RELEASING.md.
on:
push:
branches: ['release/**']
concurrency:
group: beta
cancel-in-progress: false
jobs:
detect:
# Gitea only; see the same guard in release.yaml.
if: github.repository_owner == 'makiolaj'
runs-on: docker
outputs:
is_beta: ${{ steps.v.outputs.is_beta }}
version: ${{ steps.v.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve version and whether it still needs publishing
id: v
run: |
set -e
INFO=$(bash scripts/version_info.sh)
echo "$INFO"
echo "$INFO" >> "$GITHUB_OUTPUT"
if [ "$(bash scripts/version_info.sh channel)" != beta ]; then
echo "Not a beta — nothing to do."
echo "is_beta=false" >> "$GITHUB_OUTPUT"
exit 0
fi
GATE=$(bash scripts/release_gate.sh)
echo "is_beta=${GATE#cut=}" >> "$GITHUB_OUTPUT"
beta:
needs: detect
if: needs.detect.outputs.is_beta == 'true'
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
VERSION: ${{ needs.detect.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Android build environment
uses: ./.gitea/actions/android-env
- name: Pin versionCode to versionName
run: bash scripts/version_info.sh --pin
- name: Unit tests
run: ./gradlew testDebugUnitTest
# The real app key, so a beta updates in place to later betas and stable.
- name: Setup Android Keystore
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
run: bash scripts/write_keystore.sh
- name: Build release APK
run: ./gradlew assembleRelease
- name: Create tag + Gitea pre-release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
SHA: ${{ github.sha }}
run: |
set -e
bash scripts/release_notes.sh "$VERSION" > release-notes.md
cat release-notes.md
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/release/mapping.txt \
bash scripts/publish_gitea_release.sh
- name: Publish pre-release to Codeberg
env:
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
SHA: ${{ github.sha }}
run: |
set -e
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/release/app-release.apk \
bash scripts/publish_codeberg_release.sh
+32 -255
View File
@@ -7,7 +7,9 @@ name: Release — F-Droid repo + Gitea/Codeberg release + Play
# that release to Codeberg with the signed APK + a SHA-256 checksum as a # that release to Codeberg with the signed APK + a SHA-256 checksum as a
# direct-download channel — the tag is an output of the pipeline, not its # direct-download channel — the tag is an output of the pipeline, not its
# trigger. Ordinary merges (no version bump) fall through `detect` and do # trigger. Ordinary merges (no version bump) fall through `detect` and do
# nothing. # nothing. Betas (X.Y.Z-beta.N) never come through here: beta.yaml cuts them
# from release/* branches as Codeberg-only pre-releases, and `detect` refuses
# one that reaches main.
# #
# A trailing `play` job then uploads the App Bundle to Google Play. It is last # A trailing `play` job then uploads the App Bundle to Google Play. It is last
# and separate because Play is the only channel that can reject a good build for # and separate because Play is the only channel that can reject a good build for
@@ -54,56 +56,28 @@ jobs:
- name: Resolve version and whether it is a new release - name: Resolve version and whether it is a new release
id: v id: v
env:
# Tags are read from Codeberg, which is canonical — deliberately NOT
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
# source does not have. A tag minted here on Gitea is therefore wiped
# by the next sync (Codeberg does not have it yet) and only reappears
# once the tag push at the end of this workflow propagates back.
# Asking Gitea inside that window would report "no tag" for a release
# that already shipped, and cut it a second time.
# Public repo, so this read needs no token.
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
run: | run: |
set -e set -e
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts) INFO=$(bash scripts/version_info.sh)
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi echo "$INFO"
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3) echo "$INFO" >> "$GITHUB_OUTPUT"
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0} VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) CHANNEL=$(echo "$INFO" | sed -n 's/^channel=//p')
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
echo "Resolved version $VERSION (code $VERSION_CODE)"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "Manual dispatch — re-sign path, not a release." echo "Manual dispatch — re-sign path, not a release."
echo "is_release=false" >> "$GITHUB_OUTPUT" echo "is_release=false" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi
# A tag for this version already existing means the release shipped on # Backstop for CI's guard: betas ship from release/* via beta.yaml.
# an earlier push; do nothing. Absent => this merge cuts the release. if [ "$CHANNEL" != "stable" ]; then
# echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2
# Anything other than a clean 200/404 is treated as fatal rather than exit 1
# as "no tag". A Codeberg outage or a network blip would otherwise fi
# read as absent and re-cut a release that has already shipped — # Tags are read from Codeberg, the canonical forge: a tag minted here is
# republishing to F-Droid and Play. Failing here is recoverable; a # wiped by the next mirror sync until Codeberg has it. A lookup error is
# duplicate release is not. # fatal, since guessing "no tag" would re-cut a shipped release.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000) GATE=$(bash scripts/release_gate.sh)
case "$STATUS" in echo "is_release=${GATE#cut=}" >> "$GITHUB_OUTPUT"
200)
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
;;
404)
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
echo "is_release=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
exit 1
;;
esac
# Releases: build + sign + publish, then mint the tag and Gitea release. # Releases: build + sign + publish, then mint the tag and Gitea release.
# Also runs on manual dispatch, where it skips the build and just re-signs and # Also runs on manual dispatch, where it skips the build and just re-signs and
@@ -124,65 +98,13 @@ jobs:
with: with:
submodules: recursive submodules: recursive
- name: Setup Java - name: Android build environment
uses: actions/setup-java@v4 uses: ./.gitea/actions/android-env
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Android SDK # The committed versionName is the source of truth; pin the derived code.
uses: android-actions/setup-android@v3
with:
packages: ''
- name: Setup Android SDK cache
uses: actions/cache@v4
with:
path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
"platform-tools" \
"platforms;android-37.0" \
"build-tools;36.0.0"
- name: Setup Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Install jq
run: |
set -e
SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update
$SUDO apt-get install -y jq
elif command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache jq
fi
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
# The committed versionName is the source of truth. Pin versionCode to the
# value derived from it so the published APK's code is always
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
- name: Pin versionCode to versionName - name: Pin versionCode to versionName
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
run: | run: bash scripts/version_info.sh --pin
set -e
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
grep -E 'versionName|versionCode' app/build.gradle.kts
# Test the exact commit being shipped (only on a real release). # Test the exact commit being shipped (only on a real release).
- name: Unit tests - name: Unit tests
@@ -195,15 +117,7 @@ jobs:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }} KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
run: | run: bash scripts/write_keystore.sh
mkdir -p app
echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks
cat > key.properties <<EOF
storePassword=$KEY_PASSWORD
keyPassword=$KEY_PASSWORD
keyAlias=$KEY_ALIAS
storeFile=upload-keystore.jks
EOF
- name: Build release APK - name: Build release APK
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
@@ -312,6 +226,7 @@ jobs:
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at # Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
# this commit, so the tag only ever marks a fully-shipped release (and a # this commit, so the tag only ever marks a fully-shipped release (and a
# failure before here leaves no tag, so re-running the workflow retries). # failure before here leaves no tag, so re-running the workflow retries).
# Also attaches the R8 mapping, best-effort.
- name: Create tag + Gitea release - name: Create tag + Gitea release
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
env: env:
@@ -320,74 +235,10 @@ jobs:
SHA: ${{ github.sha }} SHA: ${{ github.sha }}
run: | run: |
set -e set -e
TAG="v$VERSION" bash scripts/release_notes.sh "$VERSION" > release-notes.md
# Notes = this version's CHANGELOG section. TAG="v$VERSION" PRERELEASE=false NOTES_FILE=release-notes.md \
awk -v ver="$VERSION" ' MAPPING=app/build/outputs/mapping/release/mapping.txt \
$0 ~ "^## \\[" ver "\\]" { flag = 1; next } bash scripts/publish_gitea_release.sh
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
sed -i -e '/./,$!d' release-notes.md
if [ ! -s release-notes.md ]; then
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
fi
python3 - "$TAG" "$SHA" <<'PY' > payload.json
import json, sys
print(json.dumps({
"tag_name": sys.argv[1],
"target_commitish": sys.argv[2],
"name": sys.argv[1],
"body": open("release-notes.md").read(),
"draft": False,
"prerelease": False,
}))
PY
# Upsert (re-run safe): PATCH if a release for the tag already exists,
# else POST a new one (which also creates the tag at target_commitish).
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
if [ -n "$ID" ]; then
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @payload.json "$API/releases/$ID")
OK=200
else
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @payload.json "$API/releases")
OK=201
fi
cat response.json
if [ "$CODE" != "$OK" ]; then
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
exit 1
fi
echo "Created/updated release $TAG at $SHA"
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
# Attached to the release (it's not an APK, so it fits the no-binaries
# rule). Best-effort: never fail a release over it.
- name: Attach R8 mapping to Gitea release
if: env.IS_RELEASE == 'true'
continue-on-error: true
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
MAP="app/build/outputs/mapping/release/mapping.txt"
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
TAG="v$VERSION"
ASSET="mapping-${VERSION}.txt.gz"
gzip -c "$MAP" > "/tmp/$ASSET"
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
# Replace any prior asset of the same name (re-run safe).
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$ASSET" \
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
# Mirror the release to the Codeberg mirror as a direct-download channel # Mirror the release to the Codeberg mirror as a direct-download channel
# for users who don't want F-Droid. Gitea already push-mirrors branches + # for users who don't want F-Droid. Gitea already push-mirrors branches +
@@ -406,84 +257,10 @@ jobs:
SHA: ${{ github.sha }} SHA: ${{ github.sha }}
run: | run: |
set -e set -e
if [ -z "${TOKEN:-}" ]; then [ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish." TAG="v$VERSION" PRERELEASE=false NOTES_FILE=release-notes.md \
exit 0 APK=app/build/outputs/apk/release/app-release.apk \
fi bash scripts/publish_codeberg_release.sh
TAG="v$VERSION"
APK="app/build/outputs/apk/release/app-release.apk"
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
ASSET_APK="calendula_v${VERSION}.apk"
ASSET_SUM="${ASSET_APK}.sha256"
cp "$APK" "/tmp/$ASSET_APK"
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
# Release notes: reuse the section extracted for the Gitea release,
# fall back to the CHANGELOG entry if that step's file is gone.
if [ ! -s release-notes.md ]; then
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
sed -i -e '/./,$!d' release-notes.md
fi
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
# The pipeline creates the tag via the Gitea API, which the push mirror
# (sync_on_commit only fires on real git pushes) doesn't propagate
# promptly — so a release POST that carries a target_commitish can
# outrun the mirror and 500 on a commit/tag Codeberg hasn't received.
# Push the tag straight to Codeberg so it's guaranteed present, then
# attach the release to that existing tag with NO target_commitish
# (which is what triggered the 500).
git tag -f "$TAG" "$SHA"
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/calendula.git" \
"refs/tags/$TAG"
python3 - "$TAG" <<'PY' > cb-payload.json
import json, sys
print(json.dumps({
"tag_name": sys.argv[1],
"name": sys.argv[1],
"body": open("release-notes.md").read(),
"draft": False,
"prerelease": False,
}))
PY
# Create (or update) the release. Codeberg 500s on a POST/GET against a
# tag it has only just received — the release request outruns the
# indexing of the ref we pushed a moment ago — so a single attempt kept
# failing and skipping the mirror even though the very same call
# succeeds seconds later. Retry with backoff, and PATCH in place if a
# release already exists (re-run safe). A 5xx body still exits curl 0,
# so the loop, not `set -e`, controls the flow.
ID=""
for attempt in 1 2 3 4 5 6; do
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
if [ -n "$EXIST" ]; then
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases/$EXIST"
ID="$EXIST"; break
fi
CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases")
echo "release POST attempt $attempt HTTP $CODE"
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
[ -n "$ID" ] && break
sleep $((attempt * 10))
done
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
# Attach APK + checksum, replacing any prior asset of the same name.
for A in "$ASSET_APK" "$ASSET_SUM"; do
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$A" \
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
done
echo "Published $TAG to Codeberg."
# Play takes an App Bundle, not the APK, so it is a second artifact from # Play takes an App Bundle, not the APK, so it is a second artifact from
# the same source and the same signing config — not a repackage of the # the same source and the same signing config — not a repackage of the
+11 -2
View File
@@ -1,8 +1,17 @@
name: Renovate name: Renovate
on: on:
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's # Every merge to main. Mirror syncs from Codeberg fire push events here (the
# renovate.json5 deliberately has no internal schedule (avoids double-gating). # same trigger release.yaml relies on), so a merged Renovate PR is followed
# within minutes by a run that rebases the sibling PRs it just conflicted —
# most bumps touch gradle/libs.versions.toml. Outside renovate.json5's
# `schedule` window such a run only maintains existing branches
# (updateNotScheduled), it never opens new PRs. Renovate's own rebases push
# to renovate/* branches, not main, so this cannot loop.
push:
branches: [main]
# Weekly sweep for new updates. Mondays 05:00 UTC, inside the schedule window
# in renovate.json5 — keep the two in step.
schedule: schedule:
- cron: '0 5 * * 1' - cron: '0 5 * * 1'
# Manual run for an on-demand sweep from the Actions tab. # Manual run for an on-demand sweep from the Actions tab.
+85
View File
@@ -5,6 +5,66 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- **Long press an event to select it.** In the day, 3-day and week views, a long press lifts the event and selects it. Keep your finger down and move to drag it straight away. A selected event has handles at its top and bottom: drag them to change when it starts or ends, in 15-minute steps and across midnight if you want. The bar at the top edits, duplicates, shares or deletes it, and tapping elsewhere or going back deselects it. Read-only events can be selected but not moved or resized ([#141]).
- **Scroll back to earlier days in the agenda.** Scroll up past today to see earlier days' events, loaded a month at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today ([#367]).
### Fixed
- **The agenda always starts today.** Coming from the month or day view, the agenda used to start at the day you had open there, so "Next 30 days" could show the 1st to the 30th of the month. It now opens on today with your chosen range. Backing out of a day you opened from the agenda still returns you to where you were ([#367]).
- **Multi-day events dim on the days already behind you.** With past events dimmed, an event running over several days stayed at full strength on its earlier days until it ended. Those days now dim in the agenda, month, week and day views ([#367]).
## [2.22.5] — 2026-10-07
### Fixed
- **Reopening the app the next morning shows the right day.** The month view used to mark yesterday as today and keep the day you picked the evening before selected, so you had to tap today to see its events. Every view now moves on to the new day at midnight and when you come back to the app. A view you browsed to another date stays where it is ([#373]).
## [2.22.4] — 2026-10-07
### Fixed
- **Edits and deletions show up straight away on every phone.** On some phones, Samsung among them, an edited, moved or deleted event stayed where it was until the app was restarted, so a second edit could act on an event that had already changed. Calendula now refreshes its views itself after every change, and again when you come back to the app ([#364]).
- **The home-screen widgets do less work per update.** The month widget loads the months around the one it shows instead of more than two years of events, and the agenda widget only its chosen range. This should help widgets that stayed on "loading" on slower phones ([#363]).
## [2.22.3] — 2026-10-02
### Fixed
- **The store listing is called "Calendula: Calendar" again,** with "Calendar" in each store language. Since 2.22.0 the Play listing comes from the repository, whose title was only "Calendula". The app is unchanged ([#361]).
## [2.22.2] — 2026-10-02
### Fixed
- **2.22 reaches Google Play.** The Dutch store listing used the locale code `nl`, which Play rejects, so the Play uploads of 2.22.0 and 2.22.1 failed. It now uses `nl-NL`, and CI checks every store locale against the codes Play accepts. The app is unchanged from 2.22.1 ([#359]).
## [2.22.1] — 2026-10-02
### Fixed
- **Tapping a link in an event's location opens it in the browser instead of a maps app.** Links are underlined, as in the description, and tapping the rest of the location still opens maps. A link with a capitalised `Https://`, or wrapped in `<…>` the way Outlook and Teams write them, opens correctly too. When no app can open a link, a short message says so instead of the tap doing nothing ([#357]).
## [2.22.0] — 2026-10-01
### Added
- **Day, Week, and Month all page with a swipeable pager that follows your finger** instead of cutting between dates ([#131], [#336]).
- **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]).
- **A multi-day view shows a configurable number of days side by side,** with today in the first column. Set the day count in Settings ([#193]).
- **A "Visible hours" setting fits a chosen range of hours to the screen** when Day, Week, or the new multi-day view opens; earlier and later hours stay a scroll away ([#322]).
- **Timeline views mark events that run outside the visible hours** with a coloured edge at the top or bottom, and keep them tappable underneath it ([#188]).
- **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
- **Day, multi-day, and Week share one timeline and morph into each other** when you switch views, instead of each view rebuilding its own ([#184]).
- **The back gesture previews the view you're returning to** and springs back to the current one if you cancel the gesture ([#185]).
- **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]).
- **All-day reminders due today or tomorrow say so** instead of spelling out the date ([#308]).
- **Store listing in every app language.** Google Play and F-Droid show the description in all 16 languages the app ships, and each release publishes the Play listing from the repository ([#285]).
### Fixed
- **Titles and locations stop growing at 255 characters.** Some CalDAV servers (Open-Xchange among them) silently truncate longer values on sync; Calendula now caps them first and shows a counter once you're close ([#331]).
- **The "Calendar" launcher alias is translatable.** The "App name" setting's alternate label was hard-coded to English regardless of the app's language ([#251]).
- **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]).
- **Editing one occurrence of a coloured Google series saves again.** Saving with "Only this event" kept you on the edit screen without saving anything ([#338]).
## [2.21.0] — 2026-09-23 ## [2.21.0] — 2026-09-23
### Added ### Added
@@ -1774,3 +1834,28 @@ automatically, with zero telemetry and no internet permission.
[#304]: https://codeberg.org/jlmakiola/calendula/issues/304 [#304]: https://codeberg.org/jlmakiola/calendula/issues/304
[#321]: https://codeberg.org/jlmakiola/calendula/issues/321 [#321]: https://codeberg.org/jlmakiola/calendula/issues/321
[#329]: https://codeberg.org/jlmakiola/calendula/issues/329 [#329]: https://codeberg.org/jlmakiola/calendula/issues/329
[#285]: https://codeberg.org/jlmakiola/calendula/issues/285
[#338]: https://codeberg.org/jlmakiola/calendula/issues/338
[#111]: https://codeberg.org/jlmakiola/calendula/issues/111
[#131]: https://codeberg.org/jlmakiola/calendula/issues/131
[#142]: https://codeberg.org/jlmakiola/calendula/issues/142
[#184]: https://codeberg.org/jlmakiola/calendula/issues/184
[#185]: https://codeberg.org/jlmakiola/calendula/issues/185
[#188]: https://codeberg.org/jlmakiola/calendula/issues/188
[#193]: https://codeberg.org/jlmakiola/calendula/issues/193
[#197]: https://codeberg.org/jlmakiola/calendula/issues/197
[#241]: https://codeberg.org/jlmakiola/calendula/issues/241
[#251]: https://codeberg.org/jlmakiola/calendula/issues/251
[#291]: https://codeberg.org/jlmakiola/calendula/issues/291
[#308]: https://codeberg.org/jlmakiola/calendula/issues/308
[#322]: https://codeberg.org/jlmakiola/calendula/issues/322
[#331]: https://codeberg.org/jlmakiola/calendula/issues/331
[#336]: https://codeberg.org/jlmakiola/calendula/issues/336
[#357]: https://codeberg.org/jlmakiola/calendula/issues/357
[#359]: https://codeberg.org/jlmakiola/calendula/issues/359
[#361]: https://codeberg.org/jlmakiola/calendula/issues/361
[#363]: https://codeberg.org/jlmakiola/calendula/issues/363
[#364]: https://codeberg.org/jlmakiola/calendula/issues/364
[#367]: https://codeberg.org/jlmakiola/calendula/issues/367
[#373]: https://codeberg.org/jlmakiola/calendula/issues/373
[#141]: https://codeberg.org/jlmakiola/calendula/issues/141
+29 -29
View File
@@ -1,13 +1,13 @@
# Contributing to Calendula # Contributing to Calendula
Calendula is a Material 3 Expressive calendar app that lives strictly on top of Calendula is a Material 3 Expressive calendar app that lives strictly on top of
Android's `CalendarContract` — no app database, no sync stack, no network access. Android's `CalendarContract`, with no app database, sync stack or network access.
That constraint shapes most review comments, so That constraint shapes most review comments, so skim
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) is worth skimming before you write [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) before you write code. This file
code. This file is the practical how. covers the practical side.
**[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home** — [Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home for
issues, pull requests, releases. The self-hosted Gitea instance referenced in the issues, pull requests and releases. The self-hosted Gitea instance referenced in the
release docs is build infrastructure only; there is nothing to contribute there. release docs is build infrastructure only; there is nothing to contribute there.
Be decent to the people you meet in the tracker. Be decent to the people you meet in the tracker.
@@ -18,20 +18,20 @@ Be decent to the people you meet in the tracker.
| Add a feature | **Open an issue first** and wait for a go-ahead | | Add a feature | **Open an issue first** and wait for a go-ahead |
| Fix a bug | Open an issue, then a pull request | | Fix a bug | Open an issue, then a pull request |
| Fix a typo, a comment, or docs | Just open the pull request | | Fix a typo, a comment, or docs | Just open the pull request |
| Add or fix a translation | **Don't** — [use Weblate](#translations) | | Add or fix a translation | Use [Weblate](#translations), not a pull request |
Features get an opinion before they get code: whether Calendula should do a Features get an opinion before they get code: whether Calendula should do a
thing at all is the one decision a patch can't make. A feature PR that arrives thing at all is the one decision a patch can't make. A feature PR that arrives
without a discussed issue may be closed unmerged even when the code is good — without a discussed issue may be closed unmerged even when the code is good, so
please don't spend a weekend on one first. please don't spend a weekend on one first.
Bugs are more straightforward, but still start with an issue: it's what carries Bugs are more straightforward, but still start with an issue: it's what carries
the milestone and gives the changelog something to link. the milestone and gives the changelog something to link.
Issue templates cover bug, crash, feature and question. For a crash, let the app Issue templates cover bug, crash, feature and question. For a crash, let the app
do the work — **Settings → Report a problem**, or the prompt shown after a crash, do the work: *Settings → Report a problem*, or the prompt shown after a crash,
captures the stack trace and prefills the form. The report contains app, Android captures the stack trace and prefills the form. The report contains app, Android
and device versions plus the trace; no calendar content, no personal data. and device versions plus the trace, and no calendar content or personal data.
## Which branch to target ## Which branch to target
@@ -45,20 +45,20 @@ Once your issue has a milestone, that milestone names your branch:
| `2.18.0` | `release/v2.18.0` | | `2.18.0` | `release/v2.18.0` |
Every milestone has a matching branch. If it's somehow missing, target `main` and Every milestone has a matching branch. If it's somehow missing, target `main` and
mention it in the PR — it will be retargeted. Don't pick an older release branch: mention it in the PR, and it will be retargeted. Don't pick an older release branch:
they're kept after shipping, so the newest one isn't necessarily yours. they're kept after shipping, so the newest one isn't necessarily yours.
## Translations ## Translations
**Never edit a `values-*/strings.xml` file in a pull request** — German included. **Never edit a `values-*/strings.xml` file in a pull request**, German included.
Translations are owned by a self-hosted Weblate that writes to this repository Translations are owned by a self-hosted Weblate that writes to this repository
directly, and a hand-edit is overwritten on the next sync. directly, and a hand-edit is overwritten on the next sync.
→ **[Translate Calendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)** [Translate Calendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)
Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
picks it up and offers it to translators. Partial translations are expected and picks it up and offers it to translators. Partial translations are expected and
fine — missing keys are informational. Stale and orphaned keys are not, so run fine; missing keys are informational. Stale and orphaned keys are not, so run
```sh ```sh
python3 scripts/check_translations.py python3 scripts/check_translations.py
@@ -67,7 +67,7 @@ python3 scripts/check_translations.py
before pushing. It reports those more clearly than lint's `MissingTranslation` before pushing. It reports those more clearly than lint's `MissingTranslation`
does. does.
## Build & test ## Build and test
```sh ```sh
git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git
@@ -77,11 +77,11 @@ The `floret-kit` submodule is a composite build compiled from source. An
existing clone needs `git submodule update --init --recursive`, or nothing existing clone needs `git submodule update --init --recursive`, or nothing
resolves. resolves.
- **JDK 17** — not newer; the Android Gradle Plugin requires exactly 17. Set - **JDK 17**, not newer: the Android Gradle Plugin requires exactly 17. Set
`JAVA_HOME` if your default differs. `JAVA_HOME` if your default differs.
- **Android SDK** — platform 37 (`compileSdk`) and build-tools 36.0.0, located - **Android SDK**: platform 37 (`compileSdk`) and build-tools 36.0.0, located
via `ANDROID_HOME` or a gitignored `local.properties` with `sdk.dir`. If you via `ANDROID_HOME` or a gitignored `local.properties` with `sdk.dir`. If you
go the `local.properties` route the included build needs its own copy at go the `local.properties` route, the included build needs its own copy at
`floret-kit/local.properties`; `ANDROID_HOME` covers both at once and is the `floret-kit/local.properties`; `ANDROID_HOME` covers both at once and is the
easier path. easier path.
@@ -109,21 +109,21 @@ These are the ones that turn into review comments.
1. **No network.** Calendula holds no `INTERNET` permission, and that's a 1. **No network.** Calendula holds no `INTERNET` permission, and that's a
feature rather than an oversight. Anything that would need one is a product feature rather than an oversight. Anything that would need one is a product
decision before it's a patch — the crash reporter deliberately opens a decision before it's a patch. The crash reporter opens a prefilled web issue
prefilled web issue instead of posting anything itself. on purpose instead of posting anything itself.
2. **The provider is the only database.** No Room, no cache, no local mirror of 2. **The provider is the only database.** No Room, no cache, no local mirror of
events. `CalendarContract` is the single source of truth, which is also why events. `CalendarContract` is the single source of truth, which is also why
externally synced changes work for free. externally synced changes work for free.
3. **Don't patch UI state after a write.** A `ContentObserver` re-queries and 3. **Don't patch UI state after a write.** A `ContentObserver` re-queries and
views recompose from fresh provider state. Hand-patching a list after saving views recompose from fresh provider state. Hand-patching a list after saving
appears to work, then quietly diverges from what the provider actually stored. appears to work, then drifts away from what the provider stored.
4. **`domain/` has no Android imports.** Models, validation, recurrence 4. **`domain/` has no Android imports.** Models, validation, recurrence
rendering, conflict snapshots and the `.ics` codec stay pure Kotlin so they rendering, conflict snapshots and the `.ics` codec stay pure Kotlin so they
remain JVM-testable. remain JVM-testable.
5. **Tests run on the JVM.** JUnit 5 + Truth + Turbine. The seams exist for you: 5. **Tests run on the JVM.** JUnit 5 + Truth + Turbine. The seams exist for you:
fake the data source (`FakeCalendarDataSource`), and feed mappers plain maps fake the data source (`FakeCalendarDataSource`), and feed mappers plain maps
through `ColumnReader` instead of cursors. Instrumented tests are a last through `ColumnReader` instead of cursors. Instrumented tests are a last
resort, not a default. resort.
6. **Read before touching the subtle pipelines.** Recurring writes (UNTIL vs 6. **Read before touching the subtle pipelines.** Recurring writes (UNTIL vs
DURATION, exception URIs, series splits), save-conflict detection and reminder DURATION, exception URIs, series splits), save-conflict detection and reminder
delivery (post-before-mark) follow provider-driven rules that are documented delivery (post-before-mark) follow provider-driven rules that are documented
@@ -138,14 +138,14 @@ These are the ones that turn into review comments.
## UI conventions ## UI conventions
Material 3 Expressive throughout, built from the system's own tokens and Material 3 Expressive throughout, built from the system's own tokens and
components — colour-scheme tokens rather than hardcoded colours, `ListItem` for components: colour-scheme tokens rather than hardcoded colours, `ListItem` for
settings rows. settings rows.
**Selection pickers are full-screen.** Every browse-style "choose one" surface **Selection pickers are full-screen.** Every browse-style "choose one" surface
uses floret-kit's `FullScreenPicker` / `OptionPicker`; one that needs a commit or uses floret-kit's `FullScreenPicker` / `OptionPicker`; one that needs a commit or
extra action passes it through the picker's `actions` slot. The exception is the extra action passes it through the picker's `actions` slot. The exception is the
recurring-scope chooser (*this / this and following / all*), which stays a recurring-scope chooser (*this / this and following / all*), which stays a
compact dialog — a two- or three-option decision reads better as a popup than as compact dialog, because a two- or three-option decision reads better as a popup than as
a nearly empty screen. `AlertDialog` is for plain confirmations only, and radio- a nearly empty screen. `AlertDialog` is for plain confirmations only, and radio-
or text-list dialogs aren't used at all. or text-list dialogs aren't used at all.
@@ -154,7 +154,7 @@ Shared UI machinery lives in the `floret-kit` submodule and has
changing it means a pull request against that repository plus a submodule bump changing it means a pull request against that repository plus a submodule bump
here. here.
## Commits & pull requests ## Commits and pull requests
Conventional commits, scoped to the area you touched: Conventional commits, scoped to the area you touched:
@@ -165,13 +165,13 @@ docs(architecture): record what the second review pass changed
``` ```
Types in use: `feat` `fix` `docs` `refactor` `style` `chore` `ci` `build` Types in use: `feat` `fix` `docs` `refactor` `style` `chore` `ci` `build`
`revert`. Reference the issue in the subject or the body. Keep commits small — `revert`. Reference the issue in the subject or the body. Keep commits small.
small commits revert cleanly, which matters more here than a tidy history. Small commits revert cleanly, which matters more here than a tidy history.
If your change is user-visible, add an entry under `## [Unreleased]` in If your change is user-visible, add an entry under `## [Unreleased]` in
[`CHANGELOG.md`](CHANGELOG.md). Match the surrounding voice: entries describe [`CHANGELOG.md`](CHANGELOG.md). Match the surrounding voice: entries describe
what changed *for the person using the app*, and why, not what changed in the what changed *for the person using the app*, and why. Code changes don't go
code. Link the issue and add its reference at the bottom of the file. It may get there. Link the issue and add its reference at the bottom of the file. It may get
reworded when the release is cut, so don't agonise over it. reworded when the release is cut, so don't agonise over it.
Please don't commit planning or design documents. Code, tests, architecture notes Please don't commit planning or design documents. Code, tests, architecture notes
+111 -107
View File
@@ -4,8 +4,8 @@
<h1>Calendula</h1> <h1>Calendula</h1>
<p><strong>A modern Material 3 Expressive calendar for Android.</strong><br> <p><strong>A Material 3 Expressive calendar for Android.</strong><br>
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p> It works on top of the system calendar and has no network access.</p>
<p> <p>
<a href="https://codeberg.org/jlmakiola/calendula/actions"><img src="https://codeberg.org/jlmakiola/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a> <a href="https://codeberg.org/jlmakiola/calendula/actions"><img src="https://codeberg.org/jlmakiola/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
@@ -26,84 +26,89 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
</p> </p>
<p> <p>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="16%" alt="Week view">&nbsp; <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="13%" alt="Week view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="16%" alt="Month view">&nbsp; <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="13%" alt="Month view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-day.png" width="16%" alt="Day view">&nbsp; <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-agenda.png" width="13%" alt="Agenda view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="16%" alt="Event detail">&nbsp; <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="13%" alt="Event detail">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-agenda.png" width="16%" alt="Agenda view">&nbsp; <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-recurring.png" width="13%" alt="Editing a recurring event">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="16%" alt="Reminder onboarding"> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-calendars.png" width="13%" alt="Calendars from your accounts">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/07-dark.png" width="13%" alt="Dark theme">
</p> </p>
</div> </div>
Calendula is named after the flower whose name — like the word *calendar* — Calendula is named after the flower. Both its name and the word *calendar*
comes from the Latin *kalendae*, the first day of the month. It lives come from the Latin *kalendae*, the first day of the month.
entirely on top of Android's `CalendarContract`: any calendar synced to your
device (CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply
appears, and everything you create or edit syncs back the same way. No own
database, no sync stack reinvented.
## ✨ Features The app works directly on Android's `CalendarContract`. Any calendar synced to
your device (CalDAV via DAVx5, Google, local, WebCal subscriptions) shows up in
Calendula, and whatever you create or edit syncs back the same way. Calendula
has no database or sync code of its own.
**Calendar** ## Features
- Month, week, and day views with a one-tap view switcher ### Calendar
- Full event details — attendees and their responses, reminders, recurrence
(humanized), availability, visibility, foreign time zones
- Per-calendar visibility toggle, grouped by account
**Editing** - Month, week and day views, with a switcher in the top bar
- Event details with attendees and their responses, reminders, recurrence in
plain words, availability, visibility and foreign time zones
- Calendars can be shown or hidden one by one, grouped by account
- Your contacts' birthdays, anniversaries and other dates, if you turn it on.
Contacts are read on the device and never changed.
- Create, edit, and delete events — including recurring events with scoped ### Editing
writes: *only this event*, *this and all following*, or *the whole series*
- Recurrence picker with one-tap presets and custom rules (interval, weekday
toggles, end conditions); rules it can't express are preserved verbatim
- Conflict-safe saves: if an event changed elsewhere while you were editing,
Calendula asks instead of silently overwriting
- Read-only calendars (WebCal, birthdays) are detected and respected
**Reminders** - Create, edit and delete events. For a recurring event you choose whether a
change applies to *only this event*, *this and all following*, or *the whole
series*.
- The repeat picker has presets and custom rules (interval, weekdays, end
condition). Rules it can't show are kept as they are.
- If an event changed elsewhere while you were editing it, Calendula asks
before saving over it.
- Read-only calendars such as WebCal subscriptions and birthdays are detected
and can't be edited by accident.
- Event reminders delivered by Calendula itself as notifications — ### Reminders
essential when it's your only calendar app, since Android delegates
reminder delivery to calendar apps
- Tap a reminder to land on the event
**Design & privacy** - Calendula shows event reminders as notifications itself. Android leaves
reminder delivery to calendar apps, so if Calendula is your only one, this is
where your reminders come from.
- Tapping a reminder opens the event.
- Real Material 3 Expressive throughout — dynamic color (Android 12+), ### Design and privacy
expressive motion and shapes, light/dark theme
- English and German UI plus community translations (Spanish, French, Italian,
Polish, and more in progress), per-app language setting — and [open to more
languages](#-translations)
- **Zero telemetry, zero analytics, no internet permission** — your data
never leaves the device
## 📦 Install - Material 3 Expressive throughout, with dynamic color on Android 12+, light
and dark themes, and expressive motion and shapes
- Available in 16 languages, with a per-app language setting.
[Help translate](#translations).
- No telemetry, no analytics and no internet permission. Your data stays on
the device.
Pick whichever channel you already use — they all install the same app: ## Install
All channels install the same app:
| Channel | Updates | Notes | | Channel | Updates | Notes |
| --- | --- | --- | | --- | --- | --- |
| [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup | | [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup |
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; needs the repo added once | | [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; add the repo once |
| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automated by Obtainium | | [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automatic with Obtainium |
| [Google Play](#google-play) | Per release | Signed with Google's key — see below | | [Google Play](#google-play) | Per release | Signed with Google's key, see below |
| [Build from source](#build-from-source) | Whenever you build | Full control | | [Build from source](#build-from-source) | Whenever you build | Full control |
### F-Droid (recommended) ### F-Droid (recommended)
Calendula is on the **official [F-Droid](https://f-droid.org) repository** — Calendula is in the official [F-Droid](https://f-droid.org) repository. Search
just search for **Calendula** in any F-Droid client, or for Calendula in any F-Droid client, or
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/). [install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
F-Droid rebuilds from source on its own schedule, so a new version usually F-Droid builds from source on its own schedule, so a new version usually shows
shows up there a few days after release. up there a few days after release.
### Self-hosted F-Droid repo (fastest updates) ### Self-hosted F-Droid repo (fastest updates)
Every release is built, signed, and published to a self-hosted F-Droid The release pipeline builds, signs and publishes every release to a
repository as part of the release pipeline, so it lands there first. Add it once self-hosted F-Droid repository, so new versions land there first. Add it once
and your F-Droid client handles updates from then on: and your F-Droid client handles updates from then on:
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the 1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
@@ -117,88 +122,87 @@ and your F-Droid client handles updates from then on:
fingerprint (SHA-256): fingerprint (SHA-256):
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub> `C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
2. Refresh, search for **Calendula**, install. 2. Refresh, search for Calendula and install it.
### Codeberg release / Obtainium ### Codeberg release / Obtainium
If you'd rather not use F-Droid at all, every release is also published on Every release is also on
**[Codeberg](https://codeberg.org/jlmakiola/calendula/releases)** with the [Codeberg](https://codeberg.org/jlmakiola/calendula/releases) with the signed
signed APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum attached — download APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum. Download it and install
and install it directly. it directly.
For automatic updates from that channel, use For automatic updates from Codeberg, use
**[Obtainium](https://github.com/ImranR98/Obtainium)** — on the phone, [Obtainium](https://github.com/ImranR98/Obtainium). On your phone,
**[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula)**, [add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula),
or do it by hand: *Add App* → paste `https://codeberg.org/jlmakiola/calendula` or add it by hand: *Add App*, paste `https://codeberg.org/jlmakiola/calendula`,
→ *Add*. Either way, Obtainium tracks the releases and prompts you when a new then *Add*. Obtainium then watches the releases and tells you when a new one
one appears. is out. Betas of upcoming versions are published there too, as pre-releases; to
test them, switch on *Include prereleases* for Calendula in Obtainium.
### Google Play ### Google Play
Calendula is live on Google Play: Calendula is on
**[play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula)**. [Google Play](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula).
Play builds are signed with Google's key rather than mine, so switching between Play builds are signed with Google's key rather than mine, so switching between
Play and any other channel requires an uninstall (and with it, a fresh start for Play and any other channel means uninstalling first. You lose the app settings.
app settings — your events live in the system calendar and are unaffected). Your events live in the system calendar and aren't affected.
### Build from source ### Build from source
The build is a plain Gradle build with no proprietary dependencies — see It's a plain Gradle build with no proprietary dependencies. See
**[docs/BUILDING.md](docs/BUILDING.md)** (note the `floret-kit` submodule). [docs/BUILDING.md](docs/BUILDING.md), and note the `floret-kit` submodule.
<sub>Official F-Droid, the self-hosted repo, and the Codeberg releases all share <sub>Official F-Droid, the self-hosted repo and the Codeberg releases share the
the same signing key, so you can switch freely between them without same signing key, so you can switch between them without reinstalling. Google
reinstalling. Google Play is the exception — see above.</sub> Play is the exception (see above).</sub>
## 📚 Documentation ## Documentation
- **[Contributing](CONTRIBUTING.md)** — how to report, propose, and patch - [Contributing](CONTRIBUTING.md): how to report bugs, propose features and send patches
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks - [Building from source](docs/BUILDING.md): requirements and Gradle tasks
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines - [Architecture](docs/ARCHITECTURE.md): the layers and the main pipelines
- **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next - [Milestones](https://codeberg.org/jlmakiola/calendula/milestones): what has shipped and what's next
- **[Privacy policy](docs/PRIVACY.md)** — the source of the page published at - [Privacy policy](docs/PRIVACY.md): the source of the page at
[jeanlucmakiola.de/calendula/privacy](https://jeanlucmakiola.de/calendula/privacy) [jeanlucmakiola.de/calendula/privacy](https://jeanlucmakiola.de/calendula/privacy)
## 🤝 Contributing ## Contributing
Bug reports, ideas, and patches are all welcome on Bug reports, ideas and patches are welcome on
**[Codeberg](https://codeberg.org/jlmakiola/calendula/issues)**. [Codeberg](https://codeberg.org/jlmakiola/calendula/issues).
The short version: **start with an issue.** Features get a yes-or-no before they Start with an issue. A feature gets a yes or no before anyone writes code, and
get code, and both features and bugs are assigned a milestone whose features and bugs are assigned a milestone whose `release/vX.Y.Z` branch your
`release/vX.Y.Z` branch your pull request then targets. Typo and docs fixes can pull request targets. Typo and docs fixes can go straight to a pull request.
skip straight to a pull request. Translations don't go through pull requests at Translations go through [Weblate](#translations), not pull requests.
all — [Weblate owns them](#-translations).
Read **[CONTRIBUTING.md](CONTRIBUTING.md)** before writing code: it covers the Read [CONTRIBUTING.md](CONTRIBUTING.md) before writing code. It covers the
workflow, the build (note the `floret-kit` submodule), and the architectural workflow, the build (including the `floret-kit` submodule) and the
rules a change is reviewed against. architectural rules changes are reviewed against.
## 🌍 Translations ## Translations
Calendula ships in English and German, with community translations in Arabic, Calendula is written in English. Translators on Weblate have added Arabic,
Chinese, French, Italian, Polish, Portuguese, Russian, and Spanish at varying Belarusian, Chinese (Simplified), Czech, French, German, Hungarian, Italian,
degrees of completeness — partial is fine, untranslated strings simply fall back Japanese, Polish, Portuguese (Portugal and Brazil), Russian, Slovak and Spanish,
to English. You're warmly invited to add or finish your language. Translations some of them partly. Untranslated strings fall back to English, so a partial
are managed on a self-hosted **Weblate**: translation is fine. Translations are managed on a self-hosted Weblate:
**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)** [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)
No coding needed — register on the Weblate server, pick (or request) a language, You don't need to code. Register on the Weblate server, pick a language (or
and translate the strings in your browser. You can also reach this link in the request one) and translate in your browser. The app links here from the top of
app from the top of **Settings → App language**. *Settings → App language*.
## 🔒 Privacy ## Privacy
No analytics, no tracking, no advertising, no crash-reporting SDK, no third-party Calendula has no analytics, tracking, ads, crash-reporting SDK or third-party
services — and **no internet permission at all**, so the app is technically services. It doesn't have the internet permission, so it can't send anything
incapable of transmitting anything anywhere. Your events stay in Android's own anywhere. Your events stay in Android's own calendar store.
calendar store, where they already were.
**→ [Privacy policy](https://jeanlucmakiola.de/calendula/privacy)** — written in [Privacy policy](https://jeanlucmakiola.de/calendula/privacy), written in
[`docs/PRIVACY.md`](docs/PRIVACY.md), which is what that page renders. [`docs/PRIVACY.md`](docs/PRIVACY.md), which is what that page renders.
## 📜 License ## License
[MIT](LICENSE) — Jean-Luc Makiola, 2026 [MIT](LICENSE), Jean-Luc Makiola, 2026
+8 -4
View File
@@ -26,10 +26,14 @@ android {
// These committed values ARE the source of truth for a release: merging // These committed values ARE the source of truth for a release: merging
// a bumped versionName into main triggers .gitea/workflows/release.yaml, // a bumped versionName into main triggers .gitea/workflows/release.yaml,
// which builds this version and then creates the matching vX.Y.Z tag + // which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // release itself. A versionName of X.Y.Z-beta.N pushed to a release/*
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. // branch instead cuts a Codeberg-only pre-release (beta.yaml).
versionCode = 22100 // versionCode is derived from versionName by scripts/version_info.sh
versionName = "2.21.0" // (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md.
versionCode = 2230002
versionName = "2.23.0-beta.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -26,12 +26,14 @@ import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
import de.jeanlucmakiola.calendula.ui.RootScreen import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid 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.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
@@ -55,6 +57,7 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject
/** A prefilled create form from an external launch, with the source it came from. */ /** A prefilled create form from an external launch, with the source it came from. */
private data class InsertRequest(val form: EventForm, val source: ImportSource) private data class InsertRequest(val form: EventForm, val source: ImportSource)
@@ -62,6 +65,8 @@ private data class InsertRequest(val form: EventForm, val source: ImportSource)
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@Inject lateinit var calendarRepository: CalendarRepository
// Which of light/dark the system bars are drawn for. The styles installed // Which of light/dark the system bars are drawn for. The styles installed
// in onCreate read this field live, so androidx's config-change replay // in onCreate read this field live, so androidx's config-change replay
// picks up the in-app override instead of the night resource qualifier. // picks up the in-app override instead of the night resource qualifier.
@@ -169,6 +174,7 @@ class MainActivity : AppCompatActivity() {
// weeks and switching between the week and day view. // weeks and switching between the week and day view.
val timelineZoom = rememberTimelineZoom( val timelineZoom = rememberTimelineZoom(
stored = settings.timelineScale, stored = settings.timelineScale,
hours = settings.timelineHours,
onPersist = settingsViewModel::setTimelineScale, onPersist = settingsViewModel::setTimelineScale,
) )
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle() val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
@@ -188,6 +194,7 @@ class MainActivity : AppCompatActivity() {
LocalUse24HourFormat provides use24Hour, LocalUse24HourFormat provides use24Hour,
LocalShowHourGrid provides settings.showHourGrid, LocalShowHourGrid provides settings.showHourGrid,
LocalTimelineZoom provides timelineZoom, LocalTimelineZoom provides timelineZoom,
LocalMultiDayCount provides settings.multiDayCount,
LocalSoftenColors provides settings.softenColors, LocalSoftenColors provides settings.softenColors,
) { ) {
RootScreen( RootScreen(
@@ -242,6 +249,12 @@ class MainActivity : AppCompatActivity() {
) )
} }
override fun onStart() {
super.onStart()
// Changes made while we were away may never have notified us (#364).
calendarRepository.refresh()
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
// Reaching a running UI means startup succeeded; reset the loop trail. // Reaching a running UI means startup succeeded; reset the loop trail.
@@ -610,8 +610,11 @@ class AndroidCalendarDataSource @Inject constructor(
// cancelled exception for the one instance (#47). A NULL status is a // cancelled exception for the one instance (#47). A NULL status is a
// normal, un-cancelled event, so it must survive the filter — a bare // normal, un-cancelled event, so it must survive the filter — a bare
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true). // `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
"${CalendarContract.Instances.STATUS} IS NULL OR " + // DELETED = 0: a provider that soft-deletes may keep the row's
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}", // instances until a purge (#364).
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
"${CalendarContract.Events.DELETED} = 0",
null, null,
CalendarContract.Instances.BEGIN + " ASC", CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList() )?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
@@ -1261,6 +1264,13 @@ class AndroidCalendarDataSource @Inject constructor(
values.toContentValues(), values.toContentValues(),
) ?: throw WriteFailedException("modify occurrence event id=$eventId begin=$beginMillis") ) ?: throw WriteFailedException("modify occurrence event id=$eventId begin=$beginMillis")
val exceptionId = ContentUris.parseId(uri) val exceptionId = ContentUris.parseId(uri)
form.colorKey?.let { key ->
resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, exceptionId),
ContentValues().apply { put(CalendarContract.Events.EVENT_COLOR_KEY, key) },
null, null,
)
}
// Whether the provider copied the parent's reminder rows is its // Whether the provider copied the parent's reminder rows is its
// business — reconciling against the actual rows handles both ways. // business — reconciling against the actual rows handles both ways.
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes)) reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
@@ -15,6 +15,9 @@ import kotlin.time.Instant
interface CalendarRepository { interface CalendarRepository {
fun calendars(): Flow<List<CalendarSource>> fun calendars(): Flow<List<CalendarSource>>
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
/** Re-query every open [calendars] / [instances] flow. */
fun refresh()
suspend fun eventDetail(eventId: Long): EventDetail suspend fun eventDetail(eventId: Long): EventDetail
/** /**
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.SearchCandidate
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -51,21 +52,37 @@ class CalendarRepositoryImpl @Inject constructor(
private suspend fun allDayReminderTimeMinutes(): Int = private suspend fun allDayReminderTimeMinutes(): Int =
settingsPrefs.allDayReminderTimeMinutes.first() settingsPrefs.allDayReminderTimeMinutes.first()
// DROP_OLDEST: tryEmit never fails, so a tick that lands while a subscriber
// still holds the previous one is never lost (#364).
private val ticks = MutableSharedFlow<Unit>( private val ticks = MutableSharedFlow<Unit>(
replay = 0, replay = 0,
extraBufferCapacity = 1, extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
) )
/** /**
* Bumped on every provider notification, so one tick's calendar read can be * Bumped on every [refresh], so one tick's calendar read can be
* shared by everything that needs it (see [calendarsSnapshot]). * shared by everything that needs it (see [calendarsSnapshot]).
*/ */
private val generation = AtomicLong(0L) private val generation = AtomicLong(0L)
init { init {
dataSource.registerChangeListener { dataSource.registerChangeListener(::refresh)
generation.incrementAndGet() }
ticks.tryEmit(Unit)
// Writes re-query on their own too: some providers never notify the
// observer, which left every view stale until a restart (#364).
override fun refresh() {
generation.incrementAndGet()
ticks.tryEmit(Unit)
}
/** Run a provider write on [io], then re-query whether or not it threw. */
private suspend fun <T> write(block: suspend () -> T): T = withContext(io) {
try {
block()
} finally {
refresh()
} }
} }
@@ -181,7 +198,7 @@ class CalendarRepositoryImpl @Inject constructor(
displayName: String, displayName: String,
color: Int, color: Int,
description: String?, description: String?,
): Long = withContext(io) { ): Long = write {
dataSource.createLocalCalendar(displayName, color, description) dataSource.createLocalCalendar(displayName, color, description)
} }
@@ -190,13 +207,13 @@ class CalendarRepositoryImpl @Inject constructor(
displayName: String, displayName: String,
color: Int, color: Int,
description: String?, description: String?,
) = withContext(io) { dataSource.updateCalendar(id, displayName, color, description) } ) = write { dataSource.updateCalendar(id, displayName, color, description) }
override suspend fun deleteCalendar(id: Long) = override suspend fun deleteCalendar(id: Long) =
withContext(io) { dataSource.deleteCalendar(id) } write { dataSource.deleteCalendar(id) }
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) = override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
withContext(io) { write {
if (dataSource.canWriteCalendars()) { if (dataSource.canWriteCalendars()) {
ids.forEach { dataSource.setCalendarVisible(it, visible) } ids.forEach { dataSource.setCalendarVisible(it, visible) }
// Nothing of ours is left waiting for the provider once the // Nothing of ours is left waiting for the provider once the
@@ -217,7 +234,7 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun importEvents( override suspend fun importEvents(
targetCalendarId: Long, targetCalendarId: Long,
events: List<ParsedIcsEvent>, events: List<ParsedIcsEvent>,
): IcsImportSummary = withContext(io) { ): IcsImportSummary = write {
val existing = dataSource.existingUids(targetCalendarId) val existing = dataSource.existingUids(targetCalendarId)
// Both are per-calendar, not per-event: looking them up once keeps a // Both are per-calendar, not per-event: looking them up once keeps a
// thousand-event restore to two extra queries. The palette is the // thousand-event restore to two extra queries. The palette is the
@@ -268,7 +285,7 @@ class CalendarRepositoryImpl @Inject constructor(
) )
} }
override suspend fun createEvent(form: EventForm): Long = withContext(io) { override suspend fun createEvent(form: EventForm): Long = write {
dataSource.insertEvent(form, allDayReminderTimeMinutes()) dataSource.insertEvent(form, allDayReminderTimeMinutes())
} }
@@ -276,11 +293,11 @@ class CalendarRepositoryImpl @Inject constructor(
eventId: Long, eventId: Long,
original: EventForm, original: EventForm,
updated: EventForm, updated: EventForm,
) = withContext(io) { ) = write {
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes()) dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
} }
override suspend fun deleteEvent(eventId: Long) = withContext(io) { override suspend fun deleteEvent(eventId: Long) = write {
dataSource.deleteEvent(eventId) dataSource.deleteEvent(eventId)
} }
@@ -289,7 +306,7 @@ class CalendarRepositoryImpl @Inject constructor(
targetCalendarId: Long, targetCalendarId: Long,
original: EventForm, original: EventForm,
updated: EventForm, updated: EventForm,
): Long = withContext(io) { ): Long = write {
dataSource.moveEvent( dataSource.moveEvent(
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(), eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
) )
@@ -300,7 +317,7 @@ class CalendarRepositoryImpl @Inject constructor(
beginMillis: Long, beginMillis: Long,
original: EventForm, original: EventForm,
form: EventForm, form: EventForm,
): Long = withContext(io) { ): Long = write {
dataSource.updateOccurrence( dataSource.updateOccurrence(
eventId, beginMillis, original, form, allDayReminderTimeMinutes(), eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
) )
@@ -311,20 +328,20 @@ class CalendarRepositoryImpl @Inject constructor(
beginMillis: Long, beginMillis: Long,
original: EventForm, original: EventForm,
updated: EventForm, updated: EventForm,
): Long = withContext(io) { ): Long = write {
dataSource.updateEventFromOccurrence( dataSource.updateEventFromOccurrence(
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(), eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
) )
} }
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) { override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write {
dataSource.deleteOccurrence(eventId, beginMillis) dataSource.deleteOccurrence(eventId, beginMillis)
} }
override suspend fun deleteEventFromOccurrence( override suspend fun deleteEventFromOccurrence(
eventId: Long, eventId: Long,
beginMillis: Long, beginMillis: Long,
) = withContext(io) { ) = write {
dataSource.deleteEventFromOccurrence(eventId, beginMillis) dataSource.deleteEventFromOccurrence(eventId, beginMillis)
} }
@@ -247,6 +247,10 @@ internal fun buildEventUpdateValues(
* DTSTART + DURATION itself, clearing the inherited RRULE in the process. This * DTSTART + DURATION itself, clearing the inherited RRULE in the process. This
* matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made * matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made
* "only this event" fail on-device (Codeberg #16). * "only this event" fail on-device (Codeberg #16).
*
* A palette key is left out: the provider crashes on an exception insert that
* carries `EVENT_COLOR_KEY` (Codeberg #338), so the caller sets it with a plain
* update once the row exists.
*/ */
internal fun buildOccurrenceExceptionValues( internal fun buildOccurrenceExceptionValues(
form: EventForm, form: EventForm,
@@ -264,7 +268,7 @@ internal fun buildOccurrenceExceptionValues(
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue()) put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null }) put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null }) put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
putAll(eventColorColumns(form.colorKey, form.color)) if (form.colorKey == null) putAll(eventColorColumns(null, form.color))
} }
/** /**
@@ -20,6 +20,8 @@ import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
import de.jeanlucmakiola.calendula.data.time.AndroidTodaySource
import de.jeanlucmakiola.calendula.data.time.TodaySource
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton import javax.inject.Singleton
@@ -61,6 +63,12 @@ abstract class DataBindModule {
abstract fun bindSpecialDatesCalendarSpec( abstract fun bindSpecialDatesCalendarSpec(
impl: AndroidSpecialDatesCalendarSpec, impl: AndroidSpecialDatesCalendarSpec,
): SpecialDatesCalendarSpec ): SpecialDatesCalendarSpec
@Binds
@Singleton
abstract fun bindTodaySource(
impl: AndroidTodaySource,
): TodaySource
} }
@Module @Module
@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig 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.TimelineScale
import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale
import de.jeanlucmakiola.calendula.ui.common.storageValue import de.jeanlucmakiola.calendula.ui.common.storageValue
@@ -287,6 +288,36 @@ class SettingsPrefs @Inject constructor(
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() } 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 * 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 "+" * historical layout, where it's an extended FAB that fades in above the "+"
@@ -948,10 +979,20 @@ class SettingsPrefs @Inject constructor(
if (view in config.enabled) view.name else "!${view.name}" 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> { private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
val ordered = seen.distinct() val ordered = seen.distinct().toMutableList()
return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered } 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 { companion object {
@@ -1017,8 +1058,14 @@ class SettingsPrefs @Inject constructor(
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540 internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
private const val MINUTES_PER_DAY = 1_440 private const val MINUTES_PER_DAY = 1_440
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes") 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. */ /** Default snooze delay for the notification "Snooze" action. */
const val DEFAULT_SNOOZE_MINUTES = 10 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 = internal val CALENDAR_REMINDER_OVERRIDE_KEY =
stringPreferencesKey("per_calendar_reminder_override") stringPreferencesKey("per_calendar_reminder_override")
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY = internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
@@ -88,6 +88,7 @@ class ReminderNotifier @Inject constructor(
is24Hour = is24Hour, is24Hour = is24Hour,
today = Instant.now().atZone(zone).toLocalDate(), today = Instant.now().atZone(zone).toLocalDate(),
firstDayOfWeek = firstDayOfWeek, firstDayOfWeek = firstDayOfWeek,
todayLabel = context.getString(R.string.reminder_day_today),
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow), tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
yesterdayLabel = context.getString(R.string.reminder_day_yesterday), yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
) )
@@ -27,12 +27,12 @@ import java.util.Locale
* alone would be ambiguous) * alone would be ambiguous)
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time, * - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
* already unambiguous) * already unambiguous)
* - all-day, one day: "11 Jun 2026" * - all-day, one day: "Tomorrow" / "Thu" / "11 Jun 2026" (same relative rules,
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026" * 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 * All-day instances store UTC midnights with an exclusive end, so they are read
* relative prefix. They store UTC midnights with an exclusive end, so they are * in UTC and the end day is the last *covered* day (issue #308).
* read in UTC and the end day is the last *covered* day.
*/ */
fun reminderTimeText( fun reminderTimeText(
beginMillis: Long, beginMillis: Long,
@@ -43,6 +43,7 @@ fun reminderTimeText(
is24Hour: Boolean, is24Hour: Boolean,
today: LocalDate, today: LocalDate,
firstDayOfWeek: DayOfWeek, firstDayOfWeek: DayOfWeek,
todayLabel: String,
tomorrowLabel: String, tomorrowLabel: String,
yesterdayLabel: String, yesterdayLabel: String,
): String { ): String {
@@ -53,11 +54,16 @@ fun reminderTimeText(
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate() val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
.minusDays(1) .minusDays(1)
.coerceAtLeast(firstDay) .coerceAtLeast(firstDay)
return if (lastDay == firstDay) { val start = relativeDayPrefix(
dateFormat.format(firstDay) day = firstDay,
} else { today = today,
dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay) firstDayOfWeek = firstDayOfWeek,
} locale = locale,
dateFormat = dateFormat,
tomorrowLabel = tomorrowLabel,
yesterdayLabel = yesterdayLabel,
) ?: todayLabel
return if (lastDay == firstDay) start else start + RANGE + dateFormat.format(lastDay)
} }
val timeFormat = timeOfDayFormatter(is24Hour, locale) val timeFormat = timeOfDayFormatter(is24Hour, locale)
@@ -0,0 +1,63 @@
package de.jeanlucmakiola.calendula.data.time
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.transformLatest
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import javax.inject.Inject
import kotlin.time.Clock
/** The current local date, re-emitted whenever it changes (#373). */
interface TodaySource {
val today: Flow<LocalDate>
}
/**
* Re-reads the date on every minute boundary while collected, and at once on the
* system's date, time and zone broadcasts, which can arrive late or not at all.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AndroidTodaySource @Inject constructor(
@ApplicationContext private val context: Context,
) : TodaySource {
override val today: Flow<LocalDate> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
trySend(Unit)
}
}
val filter = IntentFilter().apply {
addAction(Intent.ACTION_DATE_CHANGED)
addAction(Intent.ACTION_TIME_CHANGED)
addAction(Intent.ACTION_TIMEZONE_CHANGED)
}
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
awaitClose { context.unregisterReceiver(receiver) }
}
.onStart { emit(Unit) }
.transformLatest {
while (true) {
emit(currentDate())
delay(MINUTE_MILLIS - Clock.System.now().toEpochMilliseconds() % MINUTE_MILLIS)
}
}
.distinctUntilChanged()
}
private const val MINUTE_MILLIS = 60_000L
fun currentDate(): LocalDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
@@ -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 }
@@ -265,3 +265,20 @@ fun EventForm.problems(): Set<EventFormProblem> = buildSet {
add(EventFormProblem.RecurrenceEndsBeforeStart) add(EventFormProblem.RecurrenceEndsBeforeStart)
} }
} }
/**
* A create form seeded from this occurrence, to save a copy as a new,
* independent event (#52). The recurrence is dropped — a duplicate is a single
* event. The source calendar is kept only when [canModify], otherwise the id is
* cleared so the create resolves to a writable calendar, which lets a read-only
* event be copied into an editable one. [instance]'s own times carry over.
*/
fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
toEditForm(
beginMillis = instance.start.toEpochMilliseconds(),
endMillis = instance.end.toEpochMilliseconds(),
zone = zone,
).copy(
rrule = null,
calendarId = instance.calendarId.takeIf { canModify },
)
@@ -51,3 +51,13 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
val span = end.toInstant(zone) - start.toInstant(zone) val span = end.toInstant(zone) - start.toInstant(zone)
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone)) return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
} }
/** The form with new start and end, each in the form's own zone — a resize. All-day is untouched. */
fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm {
if (isAllDay) return this
val zone = resolvedZone(deviceZone)
return copy(
start = newStart.toLocalDateTime(zone),
end = newEnd.toLocalDateTime(zone),
)
}
@@ -1,24 +1,40 @@
package de.jeanlucmakiola.calendula.ui package de.jeanlucmakiola.calendula.ui
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility 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.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize 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.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
@@ -26,18 +42,43 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen 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.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.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventActionsViewModel
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
import de.jeanlucmakiola.calendula.ui.common.EventSelectionHost
import de.jeanlucmakiola.calendula.ui.common.eventActionFailureMessage
import de.jeanlucmakiola.calendula.ui.common.rememberFabSelection
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars 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.RescheduleViewModel
import de.jeanlucmakiola.calendula.ui.common.TimelineScrollMemory
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.ViewMorphScope
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform
import de.jeanlucmakiola.calendula.ui.common.drillToDay import de.jeanlucmakiola.calendula.ui.common.drillToDay
import de.jeanlucmakiola.calendula.ui.common.drillToWeek
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.selectView import de.jeanlucmakiola.calendula.ui.common.selectView
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack 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.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
import de.jeanlucmakiola.calendula.ui.edit.ImportSource import de.jeanlucmakiola.calendula.ui.edit.ImportSource
@@ -45,16 +86,21 @@ import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.search.SearchScreen import de.jeanlucmakiola.calendula.ui.search.SearchScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen 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.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock import kotlin.time.Clock
/** /**
* Holds the top-level view back stack (spec M1) and swaps between the calendar * Holds the top-level view back stack (spec M1) and swaps between the calendar
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher * screens. Each screen owns its own ViewModel; they all open on the one focused
* pill in their top bars writes back here via [onSelectView]. * 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. * 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 * A lateral switch (pill / drawer) builds a visit history so back retraces it
@@ -71,6 +117,7 @@ import kotlin.time.Clock
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the * as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
* widget's view.) * widget's view.)
*/ */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun CalendarHost( fun CalendarHost(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -102,14 +149,38 @@ fun CalendarHost(
var viewStack by rememberSaveable(stateSaver = viewStackSaver) { var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
mutableStateOf(listOf(defaultView)) mutableStateOf(listOf(defaultView))
} }
var navigationVersion by remember { mutableIntStateOf(0) }
// True after backing out of a day opened from the view now shown, so the
// agenda returns to where it was instead of today (#367).
var returnedFromDay by remember { mutableStateOf(false) }
val view = viewStack.last() 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()) }
// A focus still on yesterday follows the date over, so the next view opened
// doesn't land on the old day (#373); one moved elsewhere stays put.
val today = viewModel.today.collectAsStateWithLifecycle().value
var focusToday by rememberSaveable { mutableStateOf(today.toString()) }
LaunchedEffect(today) {
val old = LocalDate.parse(focusToday)
if (old != today && focus.date == old) focus.date = today
focusToday = today.toString()
}
// Tapping a day in the month grid opens the day view anchored to that date. val switchScope = rememberCoroutineScope()
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) } val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move ->
navigationVersion++
returnedFromDay = false
viewStack = move(viewStack)
}
val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } }
val onOpenDay: (LocalDate) -> Unit = { date -> val onOpenDay: (LocalDate) -> Unit = { date ->
pendingDayIso = date.toString() focus.navigateTo(date)
viewStack = viewStack.drillToDay() 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 // The event-detail screen (S4) is a full-screen destination hoisted here so
@@ -259,13 +330,17 @@ fun CalendarHost(
// [requestedDetailKey]. Date/event opens root the stack in the widget's own // [requestedDetailKey]. Date/event opens root the stack in the widget's own
// view so backing out returns there (then home), not to the default. // view so backing out returns there (then home), not to the default.
LaunchedEffect(widgetNavRequest) { LaunchedEffect(widgetNavRequest) {
if (widgetNavRequest != null) {
navigationVersion++
returnedFromDay = false
}
when (val req = widgetNavRequest) { when (val req = widgetNavRequest) {
is WidgetNavRequest.OpenDate -> { is WidgetNavRequest.OpenDate -> {
// Drill the day view in over the widget's view: drop any overlay // 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. // that would cover it, so the open doesn't land under Settings/form.
dismissCoveringOverlays() dismissCoveringOverlays()
createDateIso = null createDateIso = null
pendingDayIso = req.dateIso focus.navigateTo(LocalDate.parse(req.dateIso))
// No widget source (an external date tap) roots over the default // No widget source (an external date tap) roots over the default
// home view, so backing out of the day returns home then exits. // home view, so backing out of the day returns home then exits.
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay() viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
@@ -289,7 +364,7 @@ fun CalendarHost(
// default home — so backing out returns to the default, then exits. // default home — so backing out returns to the default, then exits.
dismissCoveringOverlays() dismissCoveringOverlays()
createDateIso = null createDateIso = null
pendingDayIso = null focus.navigateTo(localToday())
viewStack = viewBaseStack(defaultView, req.view ?: defaultView) viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
onWidgetNavConsumed() onWidgetNavConsumed()
} }
@@ -315,6 +390,9 @@ fun CalendarHost(
// survives view switches; the calendar surfaces read it out of LocalEventMove. // survives view switches; the calendar surfaces read it out of LocalEventMove.
val reschedule: RescheduleViewModel = hiltViewModel() val reschedule: RescheduleViewModel = hiltViewModel()
val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle() val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle()
val eventActions: EventActionsViewModel = hiltViewModel()
val moveOutcome by reschedule.outcome.collectAsStateWithLifecycle()
val selectionFailure by eventActions.failure.collectAsStateWithLifecycle()
val onEditEvent: (EventInstance) -> Unit = { event -> val onEditEvent: (EventInstance) -> Unit = { event ->
val key = longArrayOf( val key = longArrayOf(
event.eventId, event.eventId,
@@ -340,6 +418,9 @@ fun CalendarHost(
} }
val slideSpec = rememberCalendarSlideSpec() 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 // 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, // owns its own BackHandler and takes precedence). Disabled at the home view,
@@ -347,93 +428,206 @@ fun CalendarHost(
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null || val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
editKey != null || showSettings || showCalendars || showBackup || editKey != null || showSettings || showCalendars || showBackup ||
importUri != null || importForm != null importUri != null || importForm != null
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) { // The view switch runs on a seekable transition, so a back gesture can scrub
viewStack = viewStack.dropLast(1) // 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++
returnedFromDay = viewStack.last() == CalendarView.Day
viewStack = viewStack.dropLast(1)
}
} else {
PredictiveBackHandler(enabled = viewBackEnabled) { events ->
val previous = viewStack[viewStack.size - 2]
// Set before the seek, which already composes the previous view.
returnedFromDay = viewStack.last() == CalendarView.Day
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()) { 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 // 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() val viewSwitch = fadeThrough()
val morphBounds = rememberViewMorphBoundsTransform()
val morphFade = rememberCalendarFadeSpec()
val morphClip = remember { BelowTopBarClip() }
val stayingPut = remember { StayingPut() }
val chromeHost = remember { ChromeHost() }
val drawerState = rememberDrawerState(DrawerValue.Closed)
val density = LocalDensity.current
val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) +
with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
val timelineScroll = remember { TimelineScrollMemory() }
// A timeline reached from another screen opens on the visible hours
// (#322); only a switch between timeline views carries the scroll over.
val timelineZoom = LocalTimelineZoom.current
LaunchedEffect(view.screen) {
if (view.screen != ViewScreen.Timeline && timelineZoom.hours.enabled) {
timelineScroll.midnightY = null
}
}
CompositionLocalProvider( CompositionLocalProvider(
LocalEventMove provides moveScope, LocalEventMove provides moveScope,
LocalTimelineScroll provides timelineScroll,
LocalViewFocus provides focus,
// The failure state's way out when every calendar is switched off // The failure state's way out when every calendar is switched off
// (#239); nothing else in the calendar surfaces navigates here. // (#239); nothing else in the calendar surfaces navigates here.
LocalManageCalendars provides { showCalendars = true }, LocalManageCalendars provides remember { { showCalendars = true } },
) { ) {
AnimatedContent( // One drawer, top bar and FAB for every screen (#184): a switch changes
targetState = view, // 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 = {
// While an event is selected the FAB is its action bar's toggle (#141).
val fabSelection = rememberFabSelection(
host = chromeHost,
viewModel = eventActions,
onEdit = onEditEvent,
onDuplicate = { form ->
// A fresh event, like a duplicate from the detail screen (#52).
importFormSource = ImportSource.Insert
importForm = form
},
chipShown = moveOutcome != null || selectionFailure != null,
onDismissChip = {
reschedule.consumeOutcome()
eventActions.consumeFailure()
},
)
CalendarFab(chromeHost, todayInToolbar, fabSelection)
},
) { 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 }, transitionSpec = { viewSwitch },
label = "view-switch", contentKey = { it.screen },
) { currentView -> ) { currentView ->
when (currentView) { val screen = currentView.screen
CalendarView.Week -> WeekScreen( val sharedScope = this@SharedTransitionLayout
selectedView = currentView, val visibilityScope = this@AnimatedContent
onSelectView = onSelectView, 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, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
) )
CalendarView.Day -> DayScreen( ViewScreen.Month -> MonthScreen(
selectedView = currentView, onOpenDay = onOpenDay,
onSelectView = onSelectView, onOpenWeek = onOpenWeek,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
) )
CalendarView.Month -> MonthScreen( ViewScreen.Agenda -> AgendaScreen(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews, quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder, restorePosition = returnedFromDay,
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,
) )
} }
}
}
}
}
} }
} }
// Scope prompt + confirmation/undo snackbar for a dropped event, declared // Scope prompt + confirmation/undo snackbar for a dropped event, declared
// right after the calendar views so later overlays cover it. // right after the calendar views so later overlays cover it.
EventMoveHost(reschedule, modifier = Modifier.fillMaxSize()) EventMoveHost(
reschedule,
modifier = Modifier.fillMaxSize(),
actionFailure = eventActionFailureMessage(selectionFailure),
onActionFailureShown = eventActions::consumeFailure,
)
// The delete prompt and failure chip of a selected event's actions (#141).
EventSelectionHost(host = chromeHost, viewModel = eventActions)
// Search overlay — below detail/edit in the Box so a tapped result's // Search overlay — below detail/edit in the Box so a tapped result's
// detail screen draws on top, and closing it returns to the results. // detail screen draws on top, and closing it returns to the results.
AnimatedVisibility( AnimatedVisibility(
visible = showSearch, visible = showSearch,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
SearchScreen( SearchScreen(
onBack = { showSearch = false }, onBack = { showSearch = false },
onEventClick = onEventClick, onEventClick = onEventClick,
visible = showSearch,
) )
} }
@@ -442,7 +636,7 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = detailKey != null, visible = detailKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
activeKey?.let { key -> activeKey?.let { key ->
EventDetailScreen( EventDetailScreen(
@@ -462,6 +656,7 @@ fun CalendarHost(
importForm = form importForm = form
detailKey = null detailKey = null
}, },
visible = detailKey != null,
) )
} }
} }
@@ -470,7 +665,7 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = createDateIso != null, visible = createDateIso != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
(createDateIso ?: heldCreateIso)?.let { iso -> (createDateIso ?: heldCreateIso)?.let { iso ->
EventEditScreen( EventEditScreen(
@@ -479,6 +674,7 @@ fun CalendarHost(
onClose = { createDateIso = null }, onClose = { createDateIso = null },
onSaved = { createDateIso = null }, onSaved = { createDateIso = null },
onManageCalendars = { showCalendars = true }, onManageCalendars = { showCalendars = true },
visible = createDateIso != null,
) )
} }
} }
@@ -487,7 +683,7 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = editKey != null, visible = editKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
(editKey ?: heldEditKey)?.let { key -> (editKey ?: heldEditKey)?.let { key ->
EventEditScreen( EventEditScreen(
@@ -499,6 +695,7 @@ fun CalendarHost(
detailKey = null detailKey = null
}, },
onManageCalendars = { showCalendars = true }, onManageCalendars = { showCalendars = true },
visible = editKey != null,
) )
} }
} }
@@ -507,12 +704,13 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = showSettings, visible = showSettings,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
SettingsScreen( SettingsScreen(
onBack = { showSettings = false }, onBack = { showSettings = false },
onManageCalendars = { showCalendars = true }, onManageCalendars = { showCalendars = true },
onOpenBackup = { showBackup = true }, onOpenBackup = { showBackup = true },
visible = showSettings,
) )
} }
@@ -564,11 +762,12 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = showCalendars, visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
CalendarsScreen( CalendarsScreen(
onBack = { showCalendars = false }, onBack = { showCalendars = false },
onOpenBackup = { showBackup = true }, onOpenBackup = { showBackup = true },
visible = showCalendars,
) )
} }
@@ -576,7 +775,7 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = showBackup, visible = showBackup,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
// Settings can open this screen without the manager underneath, so // Settings can open this screen without the manager underneath, so
// its failure states' "Manage calendars" way out has to open the // its failure states' "Manage calendars" way out has to open the
@@ -603,6 +802,7 @@ fun CalendarHost(
showBackup = false showBackup = false
showCalendars = false showCalendars = false
}, },
visible = showBackup,
) )
} }
} }
@@ -614,3 +814,6 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
save = { stack -> stack.map(CalendarView::ordinal) }, save = { stack -> stack.map(CalendarView::ordinal) },
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } }, restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
) )
private fun localToday(): LocalDate =
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
@@ -4,12 +4,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.LocalDate
import javax.inject.Inject import javax.inject.Inject
/** /**
@@ -21,8 +24,16 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class CalendarHostViewModel @Inject constructor( class CalendarHostViewModel @Inject constructor(
prefs: SettingsPrefs, prefs: SettingsPrefs,
todaySource: TodaySource,
) : ViewModel() { ) : ViewModel() {
/** Today, kept current across midnight so the shared focus can follow it (#373). */
val today: StateFlow<LocalDate> = todaySource.today.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = currentDate(),
)
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn( val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L), started = SharingStarted.WhileSubscribed(5_000L),
@@ -1,5 +1,17 @@
package de.jeanlucmakiola.calendula.ui.agenda 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 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.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -18,54 +30,46 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee 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.ButtonDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text 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.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableIntState
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.ui.common.isPastOn
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.floret.identity.animateItemMotion 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.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS 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.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
@@ -81,26 +85,34 @@ import kotlin.time.Instant
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AgendaScreen( fun AgendaScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS, quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
restorePosition: Boolean = false,
viewModel: AgendaViewModel = hiltViewModel(), viewModel: AgendaViewModel = hiltViewModel(),
) { ) {
// Opens on today, not on the day another view was on (#367). Backing out of
// a day opened from here, or a recreated screen, returns to the day last at
// the top. The top day still goes back to the shared focus.
val focus = LocalViewFocus.current
val opened = rememberSaveable { mutableStateOf(false) }
val restoreTop = remember(viewModel) {
if (restorePosition || opened.value) {
viewModel.lastTopDate
} else {
viewModel.goToToday()
null
}
}
SideEffect { opened.value = true }
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle() val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle() val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
val showToday by viewModel.showToday.collectAsStateWithLifecycle() val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showRangePicker by remember { mutableStateOf(false) } var showRangePicker by remember { mutableStateOf(false) }
@@ -114,122 +126,103 @@ fun AgendaScreen(
// list scroll, not an anchor change — "today" has to bring the list back to // 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). // the top as well, and stay offered while it is scrolled away (#305).
val listState = rememberLazyListState() val listState = rememberLazyListState()
// Past days load above the anchor (#367), so the anchor's row counts as the
// start of the list.
val anchorIndex = remember { mutableIntStateOf(0) }
val scrolledAway by remember { val scrolledAway by remember {
derivedStateOf { derivedStateOf {
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 listState.firstVisibleItemIndex != anchorIndex.intValue ||
listState.firstVisibleItemScrollOffset > 0
} }
} }
// A new window is rendered from its first day, so the list belongs at the val jumpToToday: () -> Unit = {
// top. Keyed on the anchor the days arrived with, not the one just // Off today, moving the anchor is what repositions the list; on today
// 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 = {
// 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. // the window doesn't change, so the scroll back is the whole action.
if (isOnToday) scope.launch { listState.animateScrollToItem(0) } if (isOnToday) scope.launch { listState.animateScrollToItem(anchorIndex.intValue) }
viewModel.goToToday() viewModel.goToToday()
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
} }
ModalNavigationDrawer( PublishChrome(
drawerState = drawerState, ScreenChrome(
drawerContent = { title = stringResource(R.string.view_agenda),
CalendarDrawer( titleDate = null,
currentView = selectedView, drawerDate = anchor,
currentDate = anchor, awayFromToday = !isOnToday || (scrolledAway && successState != null),
drawerState = drawerState, todayText = stringResource(R.string.agenda_today_action),
viewOrder = drawerViewOrder, ),
onSelectView = { view -> ScreenActions(
onSelectView(view) onToday = jumpToToday,
scope.launch { drawerState.close() } onJumpToDate = { target ->
}, focus?.date = target
onJumpToDate = { target -> viewModel.goToDate(target)
viewModel.goToDate(target)
scope.launch { drawerState.close() }
},
onSettings = {
onOpenSettings()
scope.launch { drawerState.close() }
},
)
},
) {
Scaffold(
modifier = modifier,
topBar = {
AgendaTopBar(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
)
}, },
floatingActionButton = { onCreate = { onCreateEvent(anchor, null) },
CalendarFabColumn( ),
todayVisible = (!isOnToday || (scrolledAway && successState != null)) && )
!todayInToolbar,
todayText = stringResource(R.string.agenda_today_action), ScreenBody(modifier = modifier) { innerPadding ->
onToday = jumpToToday, Column(
onCreate = { onCreateEvent(anchor, null) }, modifier = Modifier
) .fillMaxSize()
}, .padding(innerPadding),
) { innerPadding -> ) {
Column( // One bar at the top: the "showing …" header on the left and the
modifier = Modifier // session range switcher on the right (one settings toggle).
.fillMaxSize() successState?.takeIf { it.showRangeBar }?.let { s ->
.padding(innerPadding), // end lines the selector up with whatever ends the top bar:
) { // the view switcher's background, or — once #150 hides it —
// One bar at the top: the "showing …" header on the left and the // the search icon's glyph.
// session range switcher on the right (one settings toggle). val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
successState?.takeIf { it.showRangeBar }?.let { s -> AppBarSpacing.Inset
// end lines the selector up with whatever ends the top bar: } else {
// the view switcher's background, or — once #150 hides it — AppBarSpacing.IconTrailingInset
// the search icon's glyph.
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
AppBarSpacing.Inset
} else {
AppBarSpacing.IconTrailingInset
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.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( Row(
state = state, verticalAlignment = Alignment.CenterVertically,
pastDisplay = pastDisplay,
showToday = showToday,
listState = listState,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
modifier = Modifier 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,
anchorIndex = anchorIndex,
initialTopDate = restoreTop,
onLoadEarlier = viewModel::loadEarlier,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onTopDay = if (successState?.anchor == anchor) {
{
focus?.date = it
viewModel.rememberTopDay(it)
}
} else null,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
)
} }
} }
@@ -326,6 +319,10 @@ internal fun AgendaContent(
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(), listState: LazyListState = rememberLazyListState(),
anchorIndex: MutableIntState = remember { mutableIntStateOf(0) },
initialTopDate: LocalDate? = null,
onLoadEarlier: () -> Unit = {},
onTopDay: ((LocalDate) -> Unit)? = null,
) { ) {
when (state) { when (state) {
AgendaUiState.Loading -> Box(modifier) AgendaUiState.Loading -> Box(modifier)
@@ -339,28 +336,34 @@ internal fun AgendaContent(
// away (or fade) as they end while the screen stays open. // away (or fade) as they end while the screen stays open.
val filtered = if (pastDisplay == PastEventDisplay.HIDE) { val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
state.days.mapNotNull { day -> state.days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(now) } val remaining = day.events.filterNot { it.isPastOn(day.date, now, state.zone) }
if (remaining.isEmpty()) null else day.copy(events = remaining) if (remaining.isEmpty()) null else day.copy(events = remaining)
} }
} else { } else {
state.days state.days
} }
// Anchor today with a "nothing left today" placeholder — but only when // Anchor today with a "nothing left today" placeholder — but only when
// the window actually starts on today; a jumped-to date has no today in // the agenda actually opens on today; a jumped-to date has no today
// it, so anchoring there would be misleading (#35). // in it, so anchoring there would be misleading (#35).
val days = anchorTodayIfMissing( val days = anchorTodayIfMissing(
days = filtered, days = filtered,
today = state.today, today = state.today,
enabled = showToday && state.anchor == state.today, enabled = showToday && state.anchor == state.today,
) )
if (days.isEmpty()) { // Past days alone don't count as anything coming up.
if (days.none { it.date >= state.anchor }) {
// Nothing to scroll: drop the position a previous list left, so // Nothing to scroll: drop the position a previous list left, so
// the today FAB doesn't linger over an empty screen. // the today FAB doesn't linger over an empty screen.
LaunchedEffect(Unit) { listState.scrollToItem(0) } LaunchedEffect(Unit) { listState.scrollToItem(0) }
SideEffect { anchorIndex.intValue = 0 }
AgendaEmpty(modifier) AgendaEmpty(modifier)
} else { } else {
AgendaList( AgendaList(
days = days, days = days,
anchor = state.anchor,
anchorIndex = anchorIndex,
initialTopDate = initialTopDate,
onLoadEarlier = onLoadEarlier,
today = state.today, today = state.today,
zone = state.zone, zone = state.zone,
listState = listState, listState = listState,
@@ -368,6 +371,7 @@ internal fun AgendaContent(
now = now, now = now,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onTopDay = onTopDay,
modifier = modifier, modifier = modifier,
) )
} }
@@ -379,6 +383,10 @@ internal fun AgendaContent(
@Composable @Composable
private fun AgendaList( private fun AgendaList(
days: List<AgendaDay>, days: List<AgendaDay>,
anchor: LocalDate,
anchorIndex: MutableIntState,
initialTopDate: LocalDate?,
onLoadEarlier: () -> Unit,
today: LocalDate, today: LocalDate,
zone: TimeZone, zone: TimeZone,
listState: LazyListState, listState: LazyListState,
@@ -386,10 +394,52 @@ private fun AgendaList(
now: Instant, now: Instant,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onTopDay: ((LocalDate) -> Unit)?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val itemDates = agendaItemDates(days)
val anchorRow = itemDates.indexOfFirst { it >= anchor }.coerceAtLeast(0)
SideEffect { anchorIndex.intValue = anchorRow }
// A new anchor opens the list on its row from the first frame, with past
// days above. 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. The first run
// may restore a remembered day instead.
val pendingRestore = remember { arrayOf(initialTopDate) }
DisposableEffect(anchor) {
val target = pendingRestore[0]
pendingRestore[0] = null
val index = target?.let { date -> itemDates.indexOfFirst { it >= date } }
?.takeIf { it >= 0 }
?: anchorRow
listState.requestScrollToItem(index)
onDispose {}
}
// Scrolling up near the first loaded day loads earlier days.
val currentLoadEarlier by rememberUpdatedState(onLoadEarlier)
val loadOnScrollUp = remember(listState) {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (available.y > 0 && listState.firstVisibleItemIndex < LOAD_EARLIER_WITHIN) {
currentLoadEarlier()
}
return Offset.Zero
}
}
}
if (onTopDay != null) {
val currentItemDates by rememberUpdatedState(itemDates)
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 -> currentItemDates.getOrNull(index)?.let(currentOnTopDay) }
}
}
LazyColumn( LazyColumn(
modifier = modifier, modifier = modifier.nestedScroll(loadOnScrollUp),
state = listState, state = listState,
// Bottom inset clears the FAB stack so the last row stays tappable. // Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp), contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
@@ -419,8 +469,8 @@ private fun AgendaList(
day = day.date, day = day.date,
zone = zone, zone = zone,
position = positionOf(index, day.events.size), position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now), dimmed = dimPast && event.isPastOn(day.date, now, zone),
modifier = animateItemMotion(), modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
onClick = { onEventClick(event) }, onClick = { onEventClick(event) },
) )
} }
@@ -430,6 +480,16 @@ private fun AgendaList(
} }
} }
/** How close to the first loaded row an upward scroll starts loading earlier days. */
private const val LOAD_EARLIER_WITHIN = 10
/** 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 @Composable
private fun AgendaEmpty(modifier: Modifier = Modifier) { private fun AgendaEmpty(modifier: Modifier = Modifier) {
Column( Column(
@@ -451,58 +511,3 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
) )
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AgendaTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
) {
TopAppBar(
title = {
// A plain label rather than a CalendarTitleButton, so it takes that
// one's inset and one-line clamp itself.
Text(
text = stringResource(R.string.view_agenda),
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = AppBarSpacing.TitleInset),
)
},
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,
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,
),
)
}
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.agenda package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.spanFirstDay import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.domain.spanLastDay import de.jeanlucmakiola.calendula.domain.spanLastDay
@@ -86,32 +87,27 @@ fun groupAgendaDays(
return byDay.map { (date, dayEvents) -> return byDay.map { (date, dayEvents) ->
AgendaDay( AgendaDay(
date = date, date = date,
events = dayEvents.sortedWith( events = dayEvents.sortedWith(eventDisplayComparator(zone)),
compareByDescending<EventInstance> { it.isAllDay }
.thenBy { it.start }
.thenBy { it.title },
),
) )
} }
} }
/** /**
* Ensure [today] surfaces as the first agenda day even when it carries no * Ensure [today] surfaces in the agenda even when it carries no (remaining)
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget * events, by inserting an empty-event [AgendaDay] in date order that the agenda
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and * renders as a "nothing left today" placeholder. A no-op unless [enabled], and
* when today already has its own day in [days]. Keeps today anchored at the top * when today already has its own day in [days]. Keeps today anchored so a glance
* so a glance tells today's events apart from a future day's (issue #35). * tells today's events apart from a future day's (issue #35).
*/ */
fun anchorTodayIfMissing( fun anchorTodayIfMissing(
days: List<AgendaDay>, days: List<AgendaDay>,
today: LocalDate, today: LocalDate,
enabled: Boolean, enabled: Boolean,
): List<AgendaDay> = ): List<AgendaDay> {
if (enabled && days.none { it.date == today }) { if (!enabled || days.any { it.date == today }) return days
listOf(AgendaDay(today, emptyList())) + days val at = days.indexOfFirst { it.date > today }.let { if (it < 0) days.size else it }
} else { return days.take(at) + AgendaDay(today, emptyList()) + days.drop(at)
days }
}
/** /**
* State for the Agenda view: a flat, forward-looking list of upcoming events * State for the Agenda view: a flat, forward-looking list of upcoming events
@@ -121,8 +117,10 @@ sealed interface AgendaUiState {
data object Loading : AgendaUiState data object Loading : AgendaUiState
data class Failure(val reason: FailureReason) : AgendaUiState data class Failure(val reason: FailureReason) : AgendaUiState
data class Success( data class Success(
/** First day of the loaded window (today, or a jumped-to date). */ /** The day the agenda opens on (today, or a jumped-to date). */
val anchor: LocalDate, val anchor: LocalDate,
/** First loaded day, before [anchor] once past days are loaded (#367). */
val windowStart: LocalDate,
val today: LocalDate, val today: LocalDate,
val days: List<AgendaDay>, val days: List<AgendaDay>,
/** The range currently in effect — the saved default or a session override. */ /** The range currently in effect — the saved default or a session override. */
@@ -8,6 +8,8 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
@@ -21,6 +23,8 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -28,10 +32,9 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -40,6 +43,7 @@ import javax.inject.Inject
class AgendaViewModel @Inject constructor( class AgendaViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs, settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
@@ -84,10 +88,34 @@ class AgendaViewModel @Inject constructor(
private val zone = TimeZone.currentSystemDefault() private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date get() = currentDate()
private val _anchor = MutableStateFlow(todayDate) // The anchor and how many days before it are loaded, moved together so a
val anchor: StateFlow<LocalDate> = _anchor // jump never queries a window that pairs the new anchor with the old span.
private val _window = MutableStateFlow(AgendaWindow(todayDate, PAST_CHUNK_DAYS))
val anchor: StateFlow<LocalDate> = _window
.map { it.anchor }
.stateIn(viewModelScope, SharingStarted.Eagerly, todayDate)
/** The day last scrolled to the top, so a return to the agenda lands back on it. */
var lastTopDate: LocalDate? = null
private set
private var lastToday = todayDate
/**
* Today, kept current across midnight (#373). An agenda still starting on
* yesterday moves on to the new day; one jumped elsewhere stays put.
*/
private val today: StateFlow<LocalDate> = todaySource.today
.onEach { date ->
val old = lastToday
lastToday = date
if (old != date && _window.value.anchor == old) {
_window.value = _window.value.copy(anchor = date)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
// A transient, in-view override of the saved agenda range. Held in memory // A transient, in-view override of the saved agenda range. Held in memory
// (not persisted), so it survives view switches and rotation within the // (not persisted), so it survives view switches and rotation within the
@@ -95,9 +123,17 @@ class AgendaViewModel @Inject constructor(
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null) private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
val state: StateFlow<AgendaUiState> = val state: StateFlow<AgendaUiState> =
combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart -> combine(
_window,
agendaSettings,
_rangeOverride,
weekStart,
settingsPrefs.pastEventDisplay,
) { window, settings, override, weekStart, pastDisplay ->
AgendaParams( AgendaParams(
anchor = anchor, anchor = window.anchor,
// Hidden past events leave nothing to scroll back to (#367).
pastDays = if (pastDisplay == PastEventDisplay.HIDE) 0 else window.pastDays,
range = override ?: settings.range, range = override ?: settings.range,
rangeIsOverride = override != null && override != settings.range, rangeIsOverride = override != null && override != settings.range,
weekStart = weekStart, weekStart = weekStart,
@@ -106,15 +142,16 @@ class AgendaViewModel @Inject constructor(
} }
.flatMapLatest { params -> .flatMapLatest { params ->
val window = agendaRange( val window = agendaRange(
params.anchor, params.windowStart,
params.range.dayCount(params.anchor, params.weekStart) - 1, params.pastDays + params.range.dayCount(params.anchor, params.weekStart) - 1,
zone, zone,
) )
combine( combine(
repository.calendars(), repository.calendars(),
repository.instances(window), repository.instances(window),
) { calendars, instances -> today,
buildState(params, calendars, instances) ) { calendars, instances, today ->
buildState(params, calendars, instances, today)
} }
} }
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
@@ -126,12 +163,30 @@ class AgendaViewModel @Inject constructor(
) )
fun goToToday() { fun goToToday() {
_anchor.value = todayDate goToDate(todayDate)
} }
/** Jump the agenda window to start on a specific date (drawer jump-to-date). */ /** Jump the agenda window to start on a specific date (drawer jump-to-date). */
fun goToDate(date: LocalDate) { fun goToDate(date: LocalDate) {
_anchor.value = date if (_window.value.anchor == date) return
_window.value = AgendaWindow(date, PAST_CHUNK_DAYS)
lastTopDate = null
}
/**
* Extend the window [PAST_CHUNK_DAYS] further back (#367). Ignored while the
* previous extension is still loading, so a scroll that keeps asking only
* moves one chunk at a time.
*/
fun loadEarlier() {
val loaded = (state.value as? AgendaUiState.Success)?.windowStart ?: return
val window = _window.value
if (loaded != window.start) return
_window.value = window.copy(pastDays = window.pastDays + PAST_CHUNK_DAYS)
}
fun rememberTopDay(date: LocalDate) {
lastTopDate = date
} }
/** Temporarily override the agenda range for this session (the bottom-left pill). */ /** Temporarily override the agenda range for this session (the bottom-left pill). */
@@ -144,18 +199,26 @@ class AgendaViewModel @Inject constructor(
val showBar: Boolean, val showBar: Boolean,
) )
private data class AgendaWindow(val anchor: LocalDate, val pastDays: Int) {
val start: LocalDate get() = anchor.minus(pastDays, DateTimeUnit.DAY)
}
private data class AgendaParams( private data class AgendaParams(
val anchor: LocalDate, val anchor: LocalDate,
val pastDays: Int,
val range: AgendaRange, val range: AgendaRange,
val rangeIsOverride: Boolean, val rangeIsOverride: Boolean,
val weekStart: DayOfWeek, val weekStart: DayOfWeek,
val showRangeBar: Boolean, val showRangeBar: Boolean,
) ) {
val windowStart: LocalDate get() = anchor.minus(pastDays, DateTimeUnit.DAY)
}
private fun buildState( private fun buildState(
params: AgendaParams, params: AgendaParams,
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
instances: List<EventInstance>, instances: List<EventInstance>,
today: LocalDate,
): AgendaUiState { ): AgendaUiState {
calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) } calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) }
val anchor = params.anchor val anchor = params.anchor
@@ -163,10 +226,11 @@ class AgendaViewModel @Inject constructor(
params.range.dayCount(anchor, params.weekStart) - 1, params.range.dayCount(anchor, params.weekStart) - 1,
DateTimeUnit.DAY, DateTimeUnit.DAY,
) )
val days = groupAgendaDays(anchor, rangeEnd, instances, zone) val days = groupAgendaDays(params.windowStart, rangeEnd, instances, zone)
return AgendaUiState.Success( return AgendaUiState.Success(
anchor = anchor, anchor = anchor,
today = todayDate, windowStart = params.windowStart,
today = today,
days = days, days = days,
range = params.range, range = params.range,
rangeIsOverride = params.rangeIsOverride, rangeIsOverride = params.rangeIsOverride,
@@ -177,6 +241,9 @@ class AgendaViewModel @Inject constructor(
} }
} }
/** How many days each scroll back into the past loads (#367). */
internal const val PAST_CHUNK_DAYS = 30
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */ /** Inclusive instant range from the start of [anchor] through [days] days ahead. */
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> { internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> {
val from = anchor.atStartOfDayIn(zone) val from = anchor.atStartOfDayIn(zone)
@@ -51,6 +51,7 @@ private fun sampleAgendaState(today: LocalDate, zone: TimeZone): AgendaUiState.S
val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY) val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY)
return AgendaUiState.Success( return AgendaUiState.Success(
anchor = today, anchor = today,
windowStart = today,
today = today, today = today,
days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone), days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone),
range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS), range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS),
@@ -82,6 +82,7 @@ private val RESTORE_MIME_TYPES = arrayOf(
fun BackupScreen( fun BackupScreen(
onBack: () -> Unit, onBack: () -> Unit,
onImport: (Uri) -> Unit, onImport: (Uri) -> Unit,
visible: Boolean = true,
viewModel: CalendarsViewModel = hiltViewModel(), viewModel: CalendarsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.backupState.collectAsStateWithLifecycle() val state by viewModel.backupState.collectAsStateWithLifecycle()
@@ -137,6 +138,7 @@ fun BackupScreen(
scrollable = state is BackupUiState.Ready, scrollable = state is BackupUiState.Ready,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
when (val s = state) { when (val s = state) {
BackupUiState.Loading -> BackupLoading() BackupUiState.Loading -> BackupLoading()
@@ -133,6 +133,7 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
fun CalendarsScreen( fun CalendarsScreen(
onBack: () -> Unit, onBack: () -> Unit,
onOpenBackup: () -> Unit, onOpenBackup: () -> Unit,
visible: Boolean = true,
viewModel: CalendarsViewModel = hiltViewModel(), viewModel: CalendarsViewModel = hiltViewModel(),
) { ) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle() val calendars by viewModel.calendars.collectAsStateWithLifecycle()
@@ -177,6 +178,7 @@ fun CalendarsScreen(
onConsumeError = viewModel::consumeError, onConsumeError = viewModel::consumeError,
onOpenBackup = onOpenBackup, onOpenBackup = onOpenBackup,
onBack = onBack, onBack = onBack,
visible = visible,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID }, onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id }, onEdit = { calendar -> editorSession++; editorId = calendar.id },
onSetVisible = viewModel::setCalendarVisible, onSetVisible = viewModel::setCalendarVisible,
@@ -193,6 +195,7 @@ private fun CalendarsList(
onConsumeError: () -> Unit, onConsumeError: () -> Unit,
onOpenBackup: () -> Unit, onOpenBackup: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
visible: Boolean,
onAdd: () -> Unit, onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit, onEdit: (CalendarSource) -> Unit,
onSetVisible: (Long, Boolean) -> Unit, onSetVisible: (Long, Boolean) -> Unit,
@@ -218,6 +221,7 @@ private fun CalendarsList(
onBack = onBack, onBack = onBack,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
// What the per-calendar / per-account switches below actually do. // What the per-calendar / per-account switches below actually do.
HintText(stringResource(R.string.calendars_visibility_hint)) HintText(stringResource(R.string.calendars_visibility_hint))
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap import androidx.compose.animation.core.snap
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -13,15 +14,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Constraints 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 androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlin.math.roundToInt
/** Gap a timed block leaves to its neighbours in the column. */ /** Gap a timed block leaves to its neighbours in the column. */
val BLOCK_OUTER_INSET = 1.dp val BLOCK_OUTER_INSET = 1.dp
@@ -94,9 +99,6 @@ fun rememberBlockTextMetrics(height: Dp): BlockTextMetrics {
) )
} }
/** 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]. * Lines [text] needs to render whole at [textWidth], capped at [max].
* *
@@ -126,29 +128,65 @@ fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int
* Lines the time label may take at [textWidth], out of the [spare] height left * 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. * 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 * A week column is narrower than a "09:30–11:00" range — and a 12-hour
* second line rather than lose its end — but only out of a line the title * "6:00 PM–7:00 PM" can want four lines — so the label wraps over as many as it
* measured itself as not needing, never one it would have filled. * needs rather than lose its end, but only out of lines the title measured
* itself as not needing, never one it would have filled.
*/ */
@Composable @Composable
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int { fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
val timeLineHeight = rememberTrimmedLineHeight( val timeLineHeight = rememberTrimmedLineHeight(
MaterialTheme.typography.labelSmall.asEventTime(), MaterialTheme.typography.labelSmall.asEventTime(),
) )
return if (spare >= timeLineHeight) { return blockTextLines(
blockTextLines( text = label,
text = label, // The style it is drawn in, or the budget measures a line the label
// The style it is drawn in, or the budget measures a line the label // never uses (#219).
// never uses (#219). style = MaterialTheme.typography.labelSmall.asEventTime(),
style = MaterialTheme.typography.labelSmall.asEventTime(), textWidth = textWidth,
textWidth = textWidth, max = timeLinesWithin(spare, timeLineHeight),
max = MAX_TIME_LINES, )
) }
} else {
1 /** Lines a time label may take: its own first one, plus every whole [timeLine] in [spare]. */
internal fun timeLinesWithin(spare: Dp, timeLine: Dp): Int =
if (spare < timeLine || timeLine <= 0.dp) 1 else 1 + (spare / timeLine).toInt()
/**
* The time range as a timed block draws it.
*
* Each time stays whole where the column is wide enough for one, so a 12-hour
* label never parts "6:00" from its "PM": the range breaks at the dash, which
* rides on whichever side has room for it. Where not even one time fits, the
* text wraps wherever it can.
*/
@Composable
fun rememberBlockTimeRange(start: String, end: String, textWidth: Dp): String {
val measurer = rememberTextMeasurer()
val style = MaterialTheme.typography.labelSmall.asEventTime()
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
return remember(start, end, style, widthPx, measurer) {
blockTimeRange(start, end) {
measurer.measure(text = it, style = style, softWrap = false).size.width <= widthPx
}
} }
} }
internal fun blockTimeRange(start: String, end: String, fits: (String) -> Boolean): String {
val from = start.replace(' ', NO_BREAK_SPACE)
val to = end.replace(' ', NO_BREAK_SPACE)
return when {
fits("$from–$to") -> "$from–$to"
!fits(from) || !fits(to) -> "$start–$ZERO_WIDTH_SPACE$end"
fits("$from–") -> "$from–\n$to"
fits("–$to") -> "$from\n–$to"
else -> "$from\n$to"
}
}
private const val NO_BREAK_SPACE = ' '
private const val ZERO_WIDTH_SPACE = '​'
/** /**
* A timed block's title, over at most [maxLines], breaking at word boundaries. * A timed block's title, over at most [maxLines], breaking at word boundaries.
* *
@@ -224,9 +262,13 @@ fun BlockTimeLabel(
} }
} }
/** 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 @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 * A timed block's placement, tweened rather than jumped. Continuity comes from
@@ -239,17 +281,49 @@ data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement { fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement {
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion() || val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
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()
snap() val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
} else { val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
MaterialTheme.motionScheme.fastSpatialSpec() 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,267 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
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.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
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 de.jeanlucmakiola.calendula.domain.EventInstance
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,
)
/**
* An event a long press left selected (#141), which the host answers with the
* action bar in place of the FAB. [dismiss] clears it on the screen that owns it.
*/
@Stable
class EventSelection(val event: EventInstance, val dismiss: () -> 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 selection by mutableStateOf<EventSelection?>(null)
internal set
/** Whether the selected event is being dragged or resized right now. */
var selectionBusy by mutableStateOf(false)
internal set
/** Whether the bar would cover the selected event, and so draws compact. */
var selectionCompact by mutableStateOf(false)
internal 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) }
}
/** Hands the screen's selected [event] to the host, for as long as it stays selected. */
@Composable
fun PublishSelection(event: EventInstance?, busy: Boolean, compact: Boolean, onDismiss: () -> Unit) {
val slot = LocalChromeSlot.current ?: return
val currentDismiss by rememberUpdatedState(onDismiss)
val selection = remember(event) { event?.let { EventSelection(it) { currentDismiss() } } }
val host = slot.host
val current = slot.current
DisposableEffect(host, selection, current) {
if (current) host.selection = selection
onDispose {
if (host.selection === selection) host.selection = null
// Left mid-drag, nothing else would clear these for the other views.
host.selectionBusy = false
host.selectionCompact = false
}
}
if (current) {
SideEffect {
host.selectionBusy = busy && selection != null
host.selectionCompact = compact && selection != null
}
}
}
/**
* The top bar every calendar screen shares — or, while an event is selected,
* the selection's own bar in its place (#141).
*/
@Composable
fun CalendarTopBar(
host: ChromeHost,
selectedView: CalendarView,
quickSwitchViews: List<CalendarView>,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
) {
val effects = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
AnimatedContent(
targetState = host.selection,
contentKey = { it != null },
transitionSpec = {
(fadeIn(effects) + slideInVertically { -it / 4 })
.togetherWith(fadeOut(effects) + slideOutVertically { -it / 4 })
},
label = "top-bar",
) { selection ->
if (selection != null) {
SelectionTopBar(selection)
} else {
ViewTopBar(
host = host,
selectedView = selectedView,
quickSwitchViews = quickSwitchViews,
onCycleView = onCycleView,
onOpenDrawer = onOpenDrawer,
onOpenSearch = onOpenSearch,
showTodayButton = showTodayButton,
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ViewTopBar(
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. While an event is selected the
* FAB is [selection]'s toggle instead, and it steps aside mid-gesture.
*/
@Composable
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean, selection: FabSelection? = null) {
val chrome = host.chrome ?: return
AnimatedVisibility(
visible = !host.selectionBusy,
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(),
) {
CalendarFabColumn(
todayVisible = chrome.awayFromToday && !todayInToolbar,
todayText = chrome.todayText,
onToday = { host.actions?.onToday?.invoke() },
onCreate = { host.actions?.onCreate?.invoke() },
selection = selection,
)
}
}
/**
* 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)) DrawerSectionHeader(stringResource(R.string.view_section))
viewOrder.forEachIndexed { index, view -> viewOrder.forEachIndexed { index, view ->
GroupedRow( GroupedRow(
title = stringResource(view.labelRes), title = view.label(),
position = positionOf(index, viewOrder.size), position = positionOf(index, viewOrder.size),
selected = view == currentView, selected = view == currentView,
minHeight = 56.dp, minHeight = 56.dp,
@@ -1,11 +1,20 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -15,15 +24,38 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
/** The standard FAB's size, shared by the selection bar that unrolls from it (#141). */
val FabSize = 56.dp
/** What the FAB is while an event is selected (#141): the toggle for its action bar. */
@Stable
class FabSelection(
val expanded: Boolean,
val onToggle: () -> Unit,
val bar: @Composable () -> Unit,
)
/** /**
* The FAB stack shared by the three calendar views: a persistent "+" to * The FAB stack shared by the three calendar views: a persistent "+" to
* create an event, with the jump-to-today pill appearing above it whenever * create an event, with the jump-to-today pill appearing above it whenever
* the view isn't anchored on today. * the view isn't anchored on today. While an event is selected the same FAB,
* in the same spot, opens and folds the selection's action bar, which unrolls
* from it towards the start.
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -32,13 +64,14 @@ fun CalendarFabColumn(
todayText: String, todayText: String,
onToday: () -> Unit, onToday: () -> Unit,
onCreate: () -> Unit, onCreate: () -> Unit,
selection: FabSelection? = null,
) { ) {
Column( Column(
horizontalAlignment = Alignment.End, horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = todayVisible, visible = todayVisible && selection == null,
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()), enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()), exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
) { ) {
@@ -48,11 +81,71 @@ fun CalendarFabColumn(
text = { Text(todayText) }, text = { Text(todayText) },
) )
} }
FloatingActionButton(onClick = onCreate) { // Held past the selection clearing, so the bar has something to fold away.
Icon( val heldBar = remember { mutableStateOf<(@Composable () -> Unit)?>(null) }
imageVector = Icons.Default.Add, if (selection != null && heldBar.value !== selection.bar) heldBar.value = selection.bar
contentDescription = stringResource(R.string.event_edit_new_title), // Held at the FAB's height so the taller bar can't shift the FAB.
Row(
modifier = Modifier.height(FabSize),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AnimatedVisibility(
visible = selection?.expanded == true,
enter = expandHorizontally(
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
expandFrom = Alignment.End,
) + fadeIn(),
exit = shrinkHorizontally(
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
shrinkTowards = Alignment.End,
) + fadeOut(),
) {
heldBar.value?.invoke()
}
val label = stringResource(
when {
selection == null -> R.string.event_edit_new_title
selection.expanded -> R.string.event_selection_hide
else -> R.string.event_selection_actions
},
) )
FloatingActionButton(
onClick = selection?.onToggle ?: onCreate,
modifier = Modifier.semantics { contentDescription = label },
) {
FabGlyph(selecting = selection != null, expanded = selection?.expanded == true)
}
} }
} }
} }
/** "+", or with an event selected a chevron that turns to open and fold the bar. */
@Composable
private fun FabGlyph(selecting: Boolean, expanded: Boolean) {
val spatial = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
val swap by animateFloatAsState(if (selecting) 1f else 0f, spatial, label = "fab-swap")
val turn by animateFloatAsState(if (expanded) 0f else 180f, spatial, label = "fab-turn")
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.graphicsLayer {
rotationZ = 90f * swap
scaleX = 1f - 0.5f * swap
scaleY = 1f - 0.5f * swap
alpha = (1f - swap).coerceIn(0f, 1f)
},
)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.graphicsLayer {
rotationZ = turn - 90f * (1f - swap)
scaleX = 0.5f + 0.5f * swap
scaleY = 0.5f + 0.5f * swap
alpha = swap.coerceIn(0f, 1f)
},
)
}
}
@@ -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)
}
@@ -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()
}
}
},
)
}
}
@@ -1,5 +1,13 @@
package de.jeanlucmakiola.calendula.ui.common 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.clickable
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -10,12 +18,12 @@ import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -33,7 +41,8 @@ import kotlinx.datetime.LocalDate
* drawer uses and reporting the chosen day through [onJumpToDate]. * drawer uses and reporting the chosen day through [onJumpToDate].
* *
* [currentDate] seeds the picker with whatever the bar is currently naming (the * [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.
* *
* [shortTitle] replaces [title] when the full one does not fit the width the app * [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 * bar hands the title slot (#165). Either way the line clamps to one and
@@ -42,7 +51,7 @@ import kotlinx.datetime.LocalDate
@Composable @Composable
fun CalendarTitleButton( fun CalendarTitleButton(
title: String, title: String,
currentDate: LocalDate, currentDate: LocalDate?,
onJumpToDate: (LocalDate) -> Unit, onJumpToDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
shortTitle: String = title, shortTitle: String = title,
@@ -54,6 +63,7 @@ fun CalendarTitleButton(
.heightIn(min = 48.dp) .heightIn(min = 48.dp)
.clip(MaterialTheme.shapes.large) .clip(MaterialTheme.shapes.large)
.clickable( .clickable(
enabled = currentDate != null,
onClickLabel = stringResource(R.string.drawer_jump_to_date), onClickLabel = stringResource(R.string.drawer_jump_to_date),
role = Role.Button, role = Role.Button,
) { showDatePicker = true } ) { showDatePicker = true }
@@ -71,21 +81,38 @@ fun CalendarTitleButton(
availableWidth = constraints.maxWidth, availableWidth = constraints.maxWidth,
) )
} }
Text( // Cross-fades, so switching views or paging turns the title over
text = shown, // rather than snapping it (#184).
style = style, val fadeSpec = rememberCalendarFadeSpec()
maxLines = 1, AnimatedContent(
overflow = TextOverflow.Ellipsis, 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,
) )
} }
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
if (showDatePicker) { if (showDatePicker && currentDate != null) {
CalendarDatePickerDialog( CalendarDatePickerDialog(
initial = currentDate, initial = currentDate,
onConfirm = { onConfirm = {
@@ -1,13 +1,7 @@
package de.jeanlucmakiola.calendula.ui.common 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.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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 * the reduce-motion check — now live in floret-kit's identity module
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`, * (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays * `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
* here is only what's specific to paging the calendar grid: the directional * here is only what's specific to paging the calendar grid: the specs its
* month/week/day slide and the specs that feed it. * 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 * *default* spring-physics spec from the active motion scheme, rather than a
* fixed easing curve. * fixed easing curve.
* *
@@ -40,6 +34,12 @@ import androidx.compose.ui.unit.IntOffset
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> = fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.defaultSpatialSpec() 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 * The effects spec from the active motion scheme, for the opacity half of the
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec] * transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
@@ -55,50 +55,17 @@ fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.defaultEffectsSpec() MaterialTheme.motionScheme.defaultEffectsSpec()
/** /**
* Navigating between adjacent months/weeks/days, as M3's shared-axis X: the * A switch between views (#184): the default spatial spring, a little softer, as
* outgoing page slides and fades one way while the incoming one arrives from the * the whole screen reshapes rather than a page sliding by; softer still when
* other, position on a spring and opacity on an easing curve. * [long], for a change the eye has further to follow.
*
* @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.
*/ */
fun calendarSlideTransition( @Composable
slideDir: Int, fun <T> rememberViewSwitchSpec(long: Boolean = false): FiniteAnimationSpec<T> =
spec: FiniteAnimationSpec<IntOffset>, spring(
fadeSpec: FiniteAnimationSpec<Float>, dampingRatio = VIEW_SWITCH_DAMPING,
reduceMotion: Boolean, stiffness = if (long) LONG_VIEW_SWITCH_STIFFNESS else VIEW_SWITCH_STIFFNESS,
): 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. Left off even
// where the two pages differ in height — the split grid stands as many
// rows as its month spans (#162) — since a page sliding out over the row
// below it reads as travel, and the shear reads as a fault.
sizeTransform = SizeTransform(clip = false),
) )
}
/** private const val VIEW_SWITCH_DAMPING = 0.9f
* How far a page travels, as a fraction of the container width. private const val VIEW_SWITCH_STIFFNESS = 450f
* private const val LONG_VIEW_SWITCH_STIFFNESS = 340f
* 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
@@ -1,37 +1,53 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarViewDay import androidx.compose.material.icons.filled.CalendarViewDay
import androidx.compose.material.icons.filled.CalendarViewMonth import androidx.compose.material.icons.filled.CalendarViewMonth
import androidx.compose.material.icons.filled.CalendarViewWeek import androidx.compose.material.icons.filled.CalendarViewWeek
import androidx.compose.material.icons.filled.ViewAgenda 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.graphics.vector.ImageVector
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
/** The top-level calendar views the user can switch between (spec M1). */ /** The top-level calendar views the user can switch between (spec M1). */
enum class CalendarView { enum class CalendarView {
Month, Month,
Week, Week,
MultiDay,
Day, Day,
Agenda, Agenda,
} }
/** Switcher label, shared by the top-bar pill and the drawer's View section. */ /** How many days the multi-day view shows (#193), provided once at the app root. */
@get:StringRes val LocalMultiDayCount = staticCompositionLocalOf { SettingsPrefs.DEFAULT_MULTI_DAY_COUNT }
val CalendarView.labelRes: Int
get() = when (this) { /**
CalendarView.Month -> R.string.view_month * The view's name, shared by the top-bar pill, the drawer and Settings. The
CalendarView.Week -> R.string.view_week * multi-day view is named after its day count ("3 days").
CalendarView.Day -> R.string.view_day */
CalendarView.Agenda -> R.string.view_agenda @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. */ /** Leading icon for the view in the drawer's View section. */
val CalendarView.icon: ImageVector val CalendarView.icon: ImageVector
get() = when (this) { get() = when (this) {
CalendarView.Month -> Icons.Filled.CalendarViewMonth CalendarView.Month -> Icons.Filled.CalendarViewMonth
CalendarView.Week -> Icons.Filled.CalendarViewWeek CalendarView.Week -> Icons.Filled.CalendarViewWeek
CalendarView.MultiDay -> Icons.Filled.ViewColumn
CalendarView.Day -> Icons.Filled.CalendarViewDay CalendarView.Day -> Icons.Filled.CalendarViewDay
CalendarView.Agenda -> Icons.Filled.ViewAgenda CalendarView.Agenda -> Icons.Filled.ViewAgenda
} }
@@ -41,7 +57,13 @@ val CalendarView.icon: ImageVector
* through these in order. * through these in order.
*/ */
val IMPLEMENTED_VIEWS: List<CalendarView> = 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. */ /** Next view in [available], wrapping around. Falls back to Month if absent. */
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView { fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
@@ -107,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). */ /** Push the day view as a drill-in over the current stack (no-op if already on it). */
fun List<CalendarView>.drillToDay(): List<CalendarView> = fun List<CalendarView>.drillToDay(): List<CalendarView> =
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day 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
@@ -65,9 +65,9 @@ fun rememberFieldCopier(snackbarHostState: SnackbarHostState): FieldCopier {
/** /**
* Long-press to copy [text] whole, filed on the clipboard under [label]. * 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 * [onTap] carries a field's existing tap action through, announced as
* stays unclickable rather than growing a ripple that leads nowhere, and gets * [onTapLabel]; a field without one stays unclickable rather than growing a
* its long press announced through semantics instead. That branch merges the * 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 * node it sits on, so a screen reader lands on the field itself and finds the
* action there — a bare container is never focused. * action there — a bare container is never focused.
*/ */
@@ -77,11 +77,13 @@ fun Modifier.copyOnLongPress(
text: String, text: String,
copy: FieldCopier, copy: FieldCopier,
onTap: (() -> Unit)? = null, onTap: (() -> Unit)? = null,
onTapLabel: String? = null,
): Modifier { ): Modifier {
val actionLabel = stringResource(R.string.field_copy_action) val actionLabel = stringResource(R.string.field_copy_action)
return if (onTap != null) { return if (onTap != null) {
combinedClickable( combinedClickable(
onClick = onTap, onClick = onTap,
onClickLabel = onTapLabel,
onLongClickLabel = actionLabel, onLongClickLabel = actionLabel,
onLongClick = { copy(label, text) }, onLongClick = { copy(label, text) },
) )
@@ -0,0 +1,170 @@
package de.jeanlucmakiola.calendula.ui.common
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
import de.jeanlucmakiola.floret.components.OptionCard
import kotlin.time.Clock
/**
* Serialise [detail] to a cache `.ics` and return a shareable content Uri for an
* `ACTION_SEND`, or null when the write fails. Exported as a one-off (see
* [toShareIcsEvent]). Blocking — call it off the main thread.
*/
fun IcsExporter.stageEventShare(detail: EventDetail): Uri? = runCatching {
val ics = IcsWriter().writeCalendar(
events = listOf(detail.toShareIcsEvent()),
dtStamp = Clock.System.now(),
)
stageShareFile(shareFileName(detail.instance.title), ics)
}.getOrNull()
/** Open the system chooser for a staged `.ics`; false when nothing could take it. */
fun Context.sendEventShare(uri: Uri, chooserTitle: String): Boolean = runCatching {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/calendar"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(send, chooserTitle))
}.isSuccess
/** Delete an event, or as much of its series as [scope] reaches from the occurrence at [beginMillis]. */
suspend fun CalendarRepository.deleteEvent(
eventId: Long,
beginMillis: Long,
scope: RecurringWriteScope,
) = when (scope) {
RecurringWriteScope.AllEvents -> deleteEvent(eventId)
RecurringWriteScope.ThisEvent -> deleteOccurrence(eventId, beginMillis)
RecurringWriteScope.ThisAndFollowing -> deleteEventFromOccurrence(eventId, beginMillis)
}
/**
* Runs an action that writes to the calendar, asking for `WRITE_CALENDAR` first
* where the install only holds read access (v1.0 installs). A grant continues
* straight into the action that asked for it.
*/
@Composable
fun rememberWriteGate(): (() -> Unit) -> Unit {
val context = LocalContext.current
var pending by remember { mutableStateOf<(() -> Unit)?>(null) }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) pending?.invoke()
pending = null
}
return { action ->
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
action()
} else {
pending = action
launcher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
}
/**
* Delete confirmation. Recurring events choose between cancelling just the
* tapped occurrence (default), truncating the series from it onwards, and
* removing the whole series.
*/
@Composable
fun DeleteEventDialog(
isRecurring: Boolean,
onConfirm: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
) {
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(
if (isRecurring) R.string.event_delete_recurring_title
else R.string.event_delete_title,
),
)
},
text = {
if (isRecurring) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { scope = RecurringWriteScope.ThisEvent },
selected = scope == RecurringWriteScope.ThisEvent,
)
OptionCard(
label = stringResource(R.string.event_delete_option_following),
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
selected = scope == RecurringWriteScope.ThisAndFollowing,
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { scope = RecurringWriteScope.AllEvents },
selected = scope == RecurringWriteScope.AllEvents,
)
}
} else {
Text(stringResource(R.string.event_delete_body))
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
) {
Text(
text = stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
private fun shareFileName(title: String): String {
val base = title.trim()
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
.replace(' ', '_')
.take(40)
.ifBlank { "event" }
return "$base.ics"
}
@@ -0,0 +1,131 @@
package de.jeanlucmakiola.calendula.ui.common
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import kotlin.coroutines.cancellation.CancellationException
import javax.inject.Inject
/** A delete waiting on its confirmation dialog. */
data class DeletePrompt(val event: EventInstance, val isRecurring: Boolean)
/** How an action on a selected event ended, when it has something to say. */
enum class EventActionFailure { LoadFailed, ShareFailed, DeleteFailed, WriteDenied, Gone }
/** Duplicate, share and delete for a selected event (#141), loading it on demand. */
@HiltViewModel
class EventActionsViewModel @Inject constructor(
private val repository: CalendarRepository,
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
/** Every calendar by id, for what a selected event's bar may offer. */
val calendars: StateFlow<Map<Long, CalendarSource>> = repository.calendars()
.map { list -> list.associateBy { it.id } }
.catch { emit(emptyMap()) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyMap(),
)
private val _deletePrompt = MutableStateFlow<DeletePrompt?>(null)
val deletePrompt: StateFlow<DeletePrompt?> = _deletePrompt.asStateFlow()
private val _failure = MutableStateFlow<EventActionFailure?>(null)
val failure: StateFlow<EventActionFailure?> = _failure.asStateFlow()
/** A create form copying [event], or null when it can't be read. */
suspend fun duplicateForm(event: EventInstance): EventForm? {
val detail = load(event) ?: return null
val canModify = calendars.value[event.calendarId]?.canModifyContents == true
return detail.toDuplicateForm(canModify, TimeZone.currentSystemDefault())
}
/** A staged `.ics` for [event], or null — reported — when it can't be built. */
suspend fun shareUri(event: EventInstance): Uri? {
val uri = load(event)?.let { withContext(io) { icsExporter.stageEventShare(it) } }
if (uri == null && _failure.value == null) _failure.value = EventActionFailure.ShareFailed
return uri
}
/** Ask to delete [event]; the dialog it needs depends on whether it recurs. */
fun requestDelete(event: EventInstance) {
viewModelScope.launch {
val detail = load(event) ?: return@launch
_deletePrompt.value = DeletePrompt(event, isRecurring = !detail.rrule.isNullOrBlank())
}
}
fun confirmDelete(scope: RecurringWriteScope) {
val prompt = _deletePrompt.value ?: return
_deletePrompt.value = null
viewModelScope.launch {
val event = prompt.event
_failure.value = try {
repository.deleteEvent(event.eventId, event.start.toEpochMilliseconds(), scope)
null
} catch (e: CancellationException) {
throw e
} catch (e: SecurityException) {
EventActionFailure.WriteDenied
} catch (e: NoSuchEventException) {
EventActionFailure.Gone
} catch (e: Exception) {
EventActionFailure.DeleteFailed
}
}
}
fun cancelDelete() {
_deletePrompt.value = null
}
/** The chooser itself couldn't open — nothing on the device takes an `.ics`. */
fun reportShareFailed() {
_failure.value = EventActionFailure.ShareFailed
}
fun consumeFailure() {
_failure.value = null
}
private suspend fun load(event: EventInstance): EventDetail? = try {
val detail = repository.eventDetail(event.eventId)
// The row holds the series start; the actions work on this occurrence.
detail.copy(instance = detail.instance.copy(start = event.start, end = event.end))
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
_failure.value = EventActionFailure.Gone
null
} catch (e: Exception) {
_failure.value = EventActionFailure.LoadFailed
null
}
}
@@ -24,31 +24,49 @@ import androidx.compose.ui.unit.dp
/** How far the finger may wander during the hold — well under touch slop. */ /** How far the finger may wander during the hold — well under touch slop. */
private val PICKUP_TOLERANCE = 6.dp private val PICKUP_TOLERANCE = 6.dp
/** What a long press on a block turned into. */
enum class PickUp {
/** Nothing to pick up; the gesture is left alone. */
None,
/** Lifted and following the finger. */
Drag,
/** Held but not movable — the gesture is swallowed until the finger lifts. */
Hold,
}
/** /**
* Pick an event block up with a long press and drag it, without * Pick an event block up with a long press and drag it, without
* `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an * `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an
* ancestor consumes or the finger leaves the block, which a * ancestor consumes or the finger leaves the block, which a
* `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout * `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout
* is deliberately not consumed, so a scroll starting on top of a block survives. * is deliberately not consumed, so a scroll starting on top of a block survives.
*
* [onRelease] is the long press that never moved: the finger lifted within touch
* slop of where it was picked up, or a [PickUp.Hold] ended. Without it, such a
* release is a drop like any other. [immediate] also lifts the block as soon as
* the finger moves past touch slop, with no hold — for a block already selected.
*/ */
@Composable @Composable
fun rememberEventDragSource( fun rememberEventDragSource(
enabled: Boolean, enabled: Boolean,
key: Any?, key: Any?,
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> Unit, onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> PickUp,
onMove: (pointerInRoot: Offset) -> Unit, onMove: (pointerInRoot: Offset) -> Unit,
onDrop: () -> Unit, onDrop: () -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
): Modifier = rememberDragSurface( onRelease: (() -> Unit)? = null,
immediate: Boolean = false,
): Modifier = rememberPickUpSurface(
enabled = enabled, enabled = enabled,
key = key, key = key,
onPickUp = { _, pointerInRoot, nodeInRoot, size -> immediate = immediate,
onPickUp(pointerInRoot, nodeInRoot, size) onPickUp = { _, pointerInRoot, nodeInRoot, size -> onPickUp(pointerInRoot, nodeInRoot, size) },
true
},
onMove = onMove, onMove = onMove,
onDrop = onDrop, onDrop = onDrop,
onCancel = onCancel, onCancel = onCancel,
onRelease = onRelease,
) )
/** /**
@@ -65,6 +83,29 @@ fun rememberDragSurface(
onMove: (pointerInRoot: Offset) -> Unit, onMove: (pointerInRoot: Offset) -> Unit,
onDrop: () -> Unit, onDrop: () -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
): Modifier = rememberPickUpSurface(
enabled = enabled,
key = key,
immediate = false,
onPickUp = { local, pointer, node, size ->
if (onPickUp(local, pointer, node, size)) PickUp.Drag else PickUp.None
},
onMove = onMove,
onDrop = onDrop,
onCancel = onCancel,
onRelease = null,
)
@Composable
private fun rememberPickUpSurface(
enabled: Boolean,
key: Any?,
immediate: Boolean,
onPickUp: (local: Offset, pointerInRoot: Offset, nodeInRoot: Offset, size: IntSize) -> PickUp,
onMove: (pointerInRoot: Offset) -> Unit,
onDrop: () -> Unit,
onCancel: () -> Unit,
onRelease: (() -> Unit)?,
): Modifier { ): Modifier {
if (!enabled) return Modifier if (!enabled) return Modifier
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
@@ -74,27 +115,42 @@ fun rememberDragSurface(
val currentMove by rememberUpdatedState(onMove) val currentMove by rememberUpdatedState(onMove)
val currentDrop by rememberUpdatedState(onDrop) val currentDrop by rememberUpdatedState(onDrop)
val currentCancel by rememberUpdatedState(onCancel) val currentCancel by rememberUpdatedState(onCancel)
val currentRelease by rememberUpdatedState(onRelease)
// Not a key: the pickup changes it, and a restart would cancel the drag.
val currentImmediate by rememberUpdatedState(immediate)
return Modifier return Modifier
.onGloballyPositioned { coordinates[0] = it } .onGloballyPositioned { coordinates[0] = it }
.pointerInput(key) { .pointerInput(key) {
val slop = viewConfiguration.touchSlop
awaitEachGesture { awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false) val down = awaitFirstDown(requireUnconsumed = false)
val origin = down.position val origin = down.position
val heldStill = withTimeoutOrNull(holdMillis) { val immediate = currentImmediate
while (true) { // Null: held still. True: an immediate pickup past slop. False: abandoned.
val outcome = withTimeoutOrNull(holdMillis) {
var result: Boolean? = null
while (result == null) {
val main = awaitPointerEvent() val main = awaitPointerEvent()
val change = main.changes.firstOrNull { it.id == down.id } ?: break val change = main.changes.firstOrNull { it.id == down.id }
if (!change.pressed) break val distance = change?.let { (it.position - origin).getDistance() } ?: 0f
if (main.changes.count { it.pressed } > 1) break result = when {
if ((change.position - origin).getDistance() > tolerance) break change == null || !change.pressed -> false
// An ancestor's claim only becomes visible once the whole main.changes.count { it.pressed } > 1 -> false
// main pass has run, so look again on the final pass. immediate && distance > slop && !change.isConsumed -> {
val final = awaitPointerEvent(PointerEventPass.Final) change.consume()
if (final.changes.any { it.isConsumed }) break true
}
!immediate && distance > tolerance -> false
// An ancestor's claim only becomes visible once the
// whole main pass has run, so look again on the final pass.
awaitPointerEvent(PointerEventPass.Final).changes.any { it.isConsumed } -> false
else -> null
}
} }
} == null result
if (!heldStill) return@awaitEachGesture }
if (outcome == false) return@awaitEachGesture
val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture
val took = currentPickUp( val took = currentPickUp(
@@ -103,8 +159,11 @@ fun rememberDragSurface(
layout.positionInRoot(), layout.positionInRoot(),
layout.size, layout.size,
) )
if (!took) return@awaitEachGesture if (took == PickUp.None) return@awaitEachGesture
// Moved already, for an immediate pickup: it can't be a release.
var moved = outcome == true
var dropped = false var dropped = false
var released = false
try { try {
// Driven on the initial pass, which runs parent → child: an // Driven on the initial pass, which runs parent → child: an
// ancestor that outranks us (the pinch) has already consumed // ancestor that outranks us (the pinch) has already consumed
@@ -116,18 +175,31 @@ fun rememberDragSurface(
if (change.isConsumed) break if (change.isConsumed) break
if (!change.pressed) { if (!change.pressed) {
change.consume() change.consume()
dropped = true if (took == PickUp.Hold || (!moved && currentRelease != null)) {
released = true
} else {
dropped = true
}
break break
} }
if (event.changes.count { it.pressed } > 1) break if (event.changes.count { it.pressed } > 1) break
change.consume() change.consume()
if (took == PickUp.Hold) continue
if ((change.position - origin).getDistance() > slop) moved = true
coordinates[0]?.takeIf { it.isAttached } coordinates[0]?.takeIf { it.isAttached }
?.let { currentMove(it.localToRoot(change.position)) } ?.let { currentMove(it.localToRoot(change.position)) }
} }
} finally { } finally {
// Also reached when the pointer node is disposed mid-drag — // Also reached when the pointer node is disposed mid-drag —
// a cancel, not a drop, and must never write. // a cancel, not a drop, and must never write.
if (dropped) currentDrop() else currentCancel() when {
released -> {
if (took == PickUp.Drag) currentCancel()
currentRelease?.invoke()
}
dropped -> currentDrop()
took == PickUp.Drag -> currentCancel()
}
} }
} }
} }
@@ -48,7 +48,12 @@ private val FAB_BAND = 88.dp
* a pill on the FAB's band, leaving the calendar visible behind it. * a pill on the FAB's band, leaving the calendar visible behind it.
*/ */
@Composable @Composable
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) { fun EventMoveHost(
viewModel: RescheduleViewModel,
modifier: Modifier = Modifier,
actionFailure: String? = null,
onActionFailureShown: () -> Unit = {},
) {
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle() val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
val outcome by viewModel.outcome.collectAsStateWithLifecycle() val outcome by viewModel.outcome.collectAsStateWithLifecycle()
val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle() val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle()
@@ -58,7 +63,9 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
prompt?.let { pending -> prompt?.let { pending ->
RecurringScopeDialog( RecurringScopeDialog(
title = stringResource(R.string.event_move_recurring_title), title = stringResource(
if (pending.resize) R.string.event_resize_recurring_title else R.string.event_move_recurring_title,
),
onSelect = viewModel::moveWithScope, onSelect = viewModel::moveWithScope,
onDismiss = viewModel::cancelScope, onDismiss = viewModel::cancelScope,
allowSeries = !pending.occurrenceOnly, allowSeries = !pending.occurrenceOnly,
@@ -69,9 +76,17 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
val moved = outcome as? MoveOutcome.Moved val moved = outcome as? MoveOutcome.Moved
val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) } val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) }
val resizedLabel = moved?.endMillis?.let { end ->
"$movedLabel–${formatTime(end, use24Hour, locale)}"
}
// A failed selection action (#141) shares the chip rather than stacking on it.
val message = when (outcome) { val message = when (outcome) {
null -> null null -> actionFailure
is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty()) is MoveOutcome.Moved -> if (resizedLabel != null) {
stringResource(R.string.event_resize_done, resizedLabel)
} else {
stringResource(R.string.event_move_done, movedLabel.orEmpty())
}
MoveOutcome.Undone -> stringResource(R.string.event_move_undone) MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied) MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
MoveOutcome.Gone -> stringResource(R.string.event_move_gone) MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
@@ -96,6 +111,12 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS) delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS)
viewModel.consumeOutcome() viewModel.consumeOutcome()
} }
// A failure waits for the chip to be free, then has its own turn.
LaunchedEffect(actionFailure, outcome == null) {
if (actionFailure == null || outcome != null) return@LaunchedEffect
delay(CHIP_MILLIS)
onActionFailureShown()
}
BoxWithConstraints(modifier = modifier.fillMaxSize()) { BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val chipMaxWidth = maxWidth - FAB_BAND val chipMaxWidth = maxWidth - FAB_BAND
@@ -113,7 +134,7 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
// write anyway, so offering it would be a button that does nothing. // write anyway, so offering it would be a button that does nothing.
val undo = content.undo?.takeIf { !writeInFlight } val undo = content.undo?.takeIf { !writeInFlight }
SnackChip( SnackChip(
visible = outcome != null, visible = outcome != null || actionFailure != null,
message = content.message, message = content.message,
maxWidth = chipMaxWidth, maxWidth = chipMaxWidth,
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null }, actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
@@ -142,3 +163,7 @@ private fun formatMovedTo(
return localizedDateFormatter(locale, skeleton) return localizedDateFormatter(locale, skeleton)
.format(Instant.ofEpochMilli(startMillis).atZone(zone)) .format(Instant.ofEpochMilli(startMillis).atZone(zone))
} }
/** "10:30" — the end of a resized event, read in the device zone like its start. */
private fun formatTime(millis: Long, use24Hour: Boolean, locale: Locale): String =
timeOfDayFormatter(use24Hour, locale).format(Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()))
@@ -131,6 +131,13 @@ fun eventDragAllowed(event: EventInstance): Boolean {
return move.dragEnabled && move.allows(event) return move.dragEnabled && move.allows(event)
} }
/**
* Whether a long press may select events at all (#141) — the gesture setting,
* whatever the event's calendar allows. A read-only event is selected but stays put.
*/
@Composable
fun eventSelectAllowed(): Boolean = LocalEventMove.current?.dragEnabled == true
/** /**
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't * A TalkBack action that opens [event] in the edit form, so rescheduling isn't
* pointer-only. Null when this event can't be moved. * pointer-only. Null when this event can't be moved.
@@ -0,0 +1,60 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/** The selected event's action bar (#141); Edit and Delete only where its calendar allows. */
@Composable
fun EventSelectionBar(
canEdit: Boolean,
canDelete: Boolean,
onEdit: () -> Unit,
onDuplicate: () -> Unit,
onShare: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
// The FAB's height and colours, so the two read as one control.
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
shadowElevation = 6.dp,
modifier = modifier.height(FabSize),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 4.dp),
) {
if (canEdit) BarAction(Icons.Default.Edit, R.string.event_detail_edit, onEdit)
BarAction(Icons.Default.ContentCopy, R.string.event_detail_duplicate, onDuplicate)
BarAction(Icons.Default.Share, R.string.event_detail_share, onShare)
if (canDelete) BarAction(Icons.Default.Delete, R.string.event_detail_delete, onDelete)
}
}
}
@Composable
private fun BarAction(icon: ImageVector, label: Int, onClick: () -> Unit) {
IconButton(onClick = onClick) {
Icon(imageVector = icon, contentDescription = stringResource(label))
}
}
@@ -0,0 +1,118 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.floret.components.SnackChipMargin
import kotlinx.coroutines.launch
/** The bottom band the selection bar and FAB cover, margin included. */
val SelectionBarClearance = FabSize + SnackChipMargin
/** The FAB's role while an event is selected (#141): its action bar's toggle. Null otherwise. */
@Composable
fun rememberFabSelection(
host: ChromeHost,
viewModel: EventActionsViewModel,
onEdit: (EventInstance) -> Unit,
onDuplicate: (EventForm) -> Unit,
chipShown: Boolean,
onDismissChip: () -> Unit,
): FabSelection? {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
val requireWrite = rememberWriteGate()
val chooserTitle = stringResource(R.string.event_share_chooser_title)
// What the FAB has been told for this selection, over what the coverage says.
var chosen by remember(host.selection?.event) { mutableStateOf<Boolean?>(null) }
// Folded while a chip is up, which then sits beside the FAB as usual.
val expanded = !chipShown && (chosen ?: !host.selectionCompact)
val currentEdit by rememberUpdatedState(onEdit)
val currentDuplicate by rememberUpdatedState(onDuplicate)
val currentDismissChip by rememberUpdatedState(onDismissChip)
val currentWrite by rememberUpdatedState(requireWrite)
val selection = host.selection ?: return null
val event = selection.event
val calendar = calendars[event.calendarId]
val canEdit = calendar?.canModifyContents == true
val canDelete = canEdit && calendar?.isManaged == false
return remember(selection, expanded, chipShown, canEdit, canDelete) {
FabSelection(
expanded = expanded,
onToggle = {
if (chipShown) currentDismissChip()
chosen = !expanded
},
bar = {
EventSelectionBar(
canEdit = canEdit,
canDelete = canDelete,
onEdit = {
currentWrite {
selection.dismiss()
currentEdit(event)
}
},
onDuplicate = {
currentWrite {
scope.launch {
val form = viewModel.duplicateForm(event) ?: return@launch
selection.dismiss()
currentDuplicate(form)
}
}
},
onShare = {
scope.launch {
val uri = viewModel.shareUri(event) ?: return@launch
if (!context.sendEventShare(uri, chooserTitle)) viewModel.reportShareFailed()
}
},
onDelete = { currentWrite { viewModel.requestDelete(event) } },
)
},
)
}
}
/** What a failed action on a selected event says, for the move host's chip to show (#141). */
@Composable
fun eventActionFailureMessage(failure: EventActionFailure?): String? = when (failure) {
null -> null
EventActionFailure.LoadFailed -> stringResource(R.string.event_action_load_failed)
EventActionFailure.ShareFailed -> stringResource(R.string.event_share_failed)
EventActionFailure.DeleteFailed -> stringResource(R.string.event_delete_failed)
EventActionFailure.WriteDenied -> stringResource(R.string.event_delete_write_denied)
EventActionFailure.Gone -> stringResource(R.string.event_move_gone)
}
/** The delete confirmation a selected event's Delete brings up (#141). */
@Composable
fun EventSelectionHost(host: ChromeHost, viewModel: EventActionsViewModel) {
val deletePrompt by viewModel.deletePrompt.collectAsStateWithLifecycle()
deletePrompt?.let { prompt ->
DeleteEventDialog(
isRecurring = prompt.isRecurring,
onConfirm = { writeScope ->
host.selection?.takeIf { it.event == prompt.event }?.dismiss()
viewModel.confirmDelete(writeScope)
},
onDismiss = viewModel::cancelDelete,
)
}
}
@@ -59,7 +59,11 @@ internal fun hourCellRadiusPx(cellHeight: Float, cellWidth: Float, maxRadius: Fl
* coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel * coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel
* height; [color] is resolved by the caller from the theme. * height; [color] is resolved by the caller from the theme.
*/ */
fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Modifier = fun Modifier.hourGridCells(
show: Boolean,
hourHeightPx: Float,
color: Color,
): Modifier =
if (!show) { if (!show) {
this this
} else { } else {
@@ -71,10 +75,11 @@ fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Mo
hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()), hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()),
) )
val cellSize = Size(size.width, cellHeight) val cellSize = Size(size.width, cellHeight)
val top = inset
for (hour in 0 until 24) { for (hour in 0 until 24) {
drawRoundRect( drawRoundRect(
color = color, color = color,
topLeft = Offset(0f, hour * hourHeightPx + inset), topLeft = Offset(0f, hour * hourHeightPx + top),
size = cellSize, size = cellSize,
cornerRadius = radius, cornerRadius = radius,
) )
@@ -19,12 +19,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
/** Width of the hour gutter down the start edge of the day and week timelines. */ /** Width of the hour gutter down the start edge of the day and week timelines. */
@@ -67,7 +70,7 @@ fun HourGutter(
val locale = currentLocale() val locale = currentLocale()
// Derived: the drag is rewritten every frame, its snapped start once a slot. // Derived: the drag is rewritten every frame, its snapped start once a slot.
val dragStartMin by remember(dragController) { val dragStartMin by remember(dragController) {
derivedStateOf { dragController.drag?.startMin } derivedStateOf { dragController.drag?.markerMin }
} }
val hourAlpha by animateFloatAsState( val hourAlpha by animateFloatAsState(
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f, targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
@@ -107,12 +110,25 @@ fun HourGutter(
} }
} }
} }
dragStartMin?.let { startMin -> // Held while it fades, so it goes out where it was rather than blinking off.
val badgeMin = remember { mutableStateOf<Int?>(null) }
dragStartMin?.let { badgeMin.value = it }
val badgeIn by animateFloatAsState(
targetValue = if (dragStartMin != null) 1f else 0f,
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
label = "gutterBadge",
)
badgeMin.value?.takeIf { badgeIn > 0f }?.let { startMin ->
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp) val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.offset(y = top) .offset(y = top)
.graphicsLayer {
alpha = badgeIn
scaleX = 0.8f + 0.2f * badgeIn
scaleY = 0.8f + 0.2f * badgeIn
}
.height(BADGE_HEIGHT) .height(BADGE_HEIGHT)
.background(MaterialTheme.colorScheme.primary, CircleShape) .background(MaterialTheme.colorScheme.primary, CircleShape)
.padding(horizontal = 4.dp) .padding(horizontal = 4.dp)
@@ -121,7 +137,8 @@ fun HourGutter(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = formatGutterTime(startMin, use24Hour, locale), // A resized end can sit on midnight: 00:00, not a clamped 23:59.
text = formatGutterTime(startMin % MINUTES_PER_DAY, use24Hour, locale),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary, color = MaterialTheme.colorScheme.onPrimary,
maxLines = 1, maxLines = 1,
@@ -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
}
}
@@ -5,6 +5,11 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -16,6 +21,17 @@ import kotlin.time.Instant
*/ */
val LocalDimCutoff = compositionLocalOf<Instant?> { null } val LocalDimCutoff = compositionLocalOf<Instant?> { null }
/**
* Whether [this] counts as past where it's drawn on [day]: once it has ended,
* or on any day before the one [now] falls in, so a multi-day event still
* running doesn't stay undimmed on the days already behind it.
*/
fun EventInstance.isPastOn(
day: LocalDate,
now: Instant,
zone: TimeZone = TimeZone.currentSystemDefault(),
): Boolean = hasEnded(now) || day < now.toLocalDateTime(zone).date
/** Opacity applied to a completed/past event chip when it is dimmed. */ /** Opacity applied to a completed/past event chip when it is dimmed. */
const val EventDimAlpha = 0.4f const val EventDimAlpha = 0.4f
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.calendula.domain.allowsEventMove
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
import de.jeanlucmakiola.calendula.domain.problems import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.realignRecurrence import de.jeanlucmakiola.calendula.domain.realignRecurrence
import de.jeanlucmakiola.calendula.domain.resizedTo
import de.jeanlucmakiola.calendula.domain.resolvedZone import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.shiftedByDays import de.jeanlucmakiola.calendula.domain.shiftedByDays
import de.jeanlucmakiola.calendula.domain.shiftedTo import de.jeanlucmakiola.calendula.domain.shiftedTo
@@ -54,6 +55,9 @@ sealed interface MoveTarget {
* disagree by a day over which zone the event's first day is resolved in. * disagree by a day over which zone the event's first day is resolved in.
*/ */
data class ByDays(val days: Int) : MoveTarget data class ByDays(val days: Int) : MoveTarget
/** New start and end instants — a timeline resize, which changes the length. */
data class Span(val start: Instant, val end: Instant) : MoveTarget
} }
/** /**
@@ -74,7 +78,7 @@ data class MoveRequest(
* re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor * re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor
* across the same midnights the occurrence crossed. * across the same midnights the occurrence crossed.
*/ */
data class MoveScopePrompt(val occurrenceOnly: Boolean) data class MoveScopePrompt(val occurrenceOnly: Boolean, val resize: Boolean = false)
/** The inverse of a completed move, for the undo action. */ /** The inverse of a completed move, for the undo action. */
data class MoveUndo( data class MoveUndo(
@@ -88,12 +92,14 @@ data class MoveUndo(
sealed interface MoveOutcome { sealed interface MoveOutcome {
/** /**
* Written. [startMillis] is where the dragged occurrence now begins, for the * Written. [startMillis] is where the dragged occurrence now begins, for the
* confirmation message; [undo] is null when the write has no clean inverse. * confirmation message, and [endMillis] where it ends — set only for a resize,
* whose message names both; [undo] is null when the write has no clean inverse.
*/ */
data class Moved( data class Moved(
val startMillis: Long, val startMillis: Long,
val isAllDay: Boolean, val isAllDay: Boolean,
val undo: MoveUndo?, val undo: MoveUndo?,
val endMillis: Long? = null,
) : MoveOutcome ) : MoveOutcome
data object Undone : MoveOutcome data object Undone : MoveOutcome
@@ -226,7 +232,10 @@ class RescheduleViewModel @Inject constructor(
} else { } else {
// Still busy: the scope dialog is now the thing in flight. // Still busy: the scope dialog is now the thing in flight.
pending = prepared pending = prepared
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign) _scopePrompt.value = MoveScopePrompt(
occurrenceOnly = !prepared.canRealign,
resize = request.target is MoveTarget.Span,
)
} }
} }
return true return true
@@ -313,6 +322,7 @@ class RescheduleViewModel @Inject constructor(
val shifted = when (val target = request.target) { val shifted = when (val target = request.target) {
is MoveTarget.Start -> original.shiftedTo(target.instant, zone) is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone) is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
is MoveTarget.Span -> original.resizedTo(target.start, target.end, zone)
} }
// A zero-distance drop is not a write. // A zero-distance drop is not a write.
if (shifted == original) return null if (shifted == original) return null
@@ -396,6 +406,7 @@ class RescheduleViewModel @Inject constructor(
startMillis = movedStartMillis(prepared), startMillis = movedStartMillis(prepared),
isAllDay = prepared.updated.isAllDay, isAllDay = prepared.updated.isAllDay,
undo = undoFor(prepared, scope), undo = undoFor(prepared, scope),
endMillis = resizedEndMillis(prepared),
) )
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
@@ -480,6 +491,14 @@ class RescheduleViewModel @Inject constructor(
null null
} }
/** Where a resized occurrence now ends; null for a move, which keeps its length. */
private fun resizedEndMillis(prepared: PreparedMove): Long? {
if (prepared.request.target !is MoveTarget.Span) return null
val form = prepared.updated
return form.end.toInstant(form.resolvedZone(TimeZone.currentSystemDefault()))
.toEpochMilliseconds()
}
/** /**
* Where the dragged occurrence now begins, in the same anchoring the views * Where the dragged occurrence now begins, in the same anchoring the views
* read dates back in: UTC midnight for an all-day event. * read dates back in: UTC midnight for an all-day event.
@@ -0,0 +1,102 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp
/** The handle's touch target, centred on the edge it moves. */
val ResizeHandleTarget = 48.dp
private val HANDLE_DOT = 16.dp
private val HANDLE_RING = 2.dp
/**
* A grip on one edge of a selected event (#141), dragged without a hold. No
* semantics: TalkBack resizes through the event's edit action.
*/
@Composable
fun ResizeHandle(
color: Color,
enabled: Boolean,
onStart: (pointerInRoot: Offset) -> Unit,
onMove: (pointerInRoot: Offset) -> Unit,
onEnd: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
val coordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
val currentStart by rememberUpdatedState(onStart)
val currentMove by rememberUpdatedState(onMove)
val currentEnd by rememberUpdatedState(onEnd)
val currentCancel by rememberUpdatedState(onCancel)
// Not a key: turning it off mid-resize must not cancel that resize.
val currentEnabled by rememberUpdatedState(enabled)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.size(ResizeHandleTarget)
.clearAndSetSemantics { }
.onGloballyPositioned { coordinates[0] = it }
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
if (!currentEnabled) return@awaitEachGesture
down.consume()
var started = false
var ended = false
try {
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
// Claimed by an ancestor that outranks us — the pinch.
if (change.isConsumed) break
if (event.changes.count { it.pressed } > 1) break
change.consume()
if (!change.pressed) {
ended = started
break
}
val root = coordinates[0]?.takeIf { it.isAttached }
?.localToRoot(change.position) ?: break
if (started) {
currentMove(root)
} else {
started = true
currentStart(root)
}
}
} finally {
when {
ended -> currentEnd()
started -> currentCancel()
}
}
}
},
) {
Box(
Modifier
.size(HANDLE_DOT)
.background(color, CircleShape)
.border(HANDLE_RING, MaterialTheme.colorScheme.surface, CircleShape),
)
}
}
@@ -0,0 +1,89 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import java.time.ZoneId
import java.util.Locale
import kotlin.time.toJavaInstant
/**
* The top bar while an event is selected (#141): says which, and when, with a
* way out — the selection otherwise only shows on the grid and in the FAB.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SelectionTopBar(selection: EventSelection) {
val event = selection.event
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val whenText = remember(event.start, event.end, use24Hour, locale) {
formatSelectionWhen(event, use24Hour, locale)
}
TopAppBar(
navigationIcon = {
IconButton(onClick = selection.dismiss) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.event_selection_close),
)
}
},
title = {
Column {
Text(
text = event.title.ifBlank { stringResource(R.string.event_untitled) },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = whenText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
// The header's own surface, as the view's bar has (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
}
/**
* "Fri, 9 Oct · 09:00–10:00", or with both days named for an event that runs
* into another: "Fri, 9 Oct, 22:00 – Sat, 10 Oct, 02:00".
*/
private fun formatSelectionWhen(event: EventInstance, use24Hour: Boolean, locale: Locale): String {
val zone = ZoneId.systemDefault()
val start = event.start.toJavaInstant().atZone(zone)
val end = event.end.toJavaInstant().atZone(zone)
val day = localizedDateFormatter(locale, "EEEdMMM")
val time = timeOfDayFormatter(use24Hour, locale)
val endsSameDay = end.toLocalDate() == start.toLocalDate() ||
(end.toLocalTime().toSecondOfDay() == 0 && end.toLocalDate() == start.toLocalDate().plusDays(1))
return if (endsSameDay) {
"${day.format(start)} · ${time.format(start)}–${time.format(end)}"
} else {
"${day.format(start)}, ${time.format(start)} – ${day.format(end)}, ${time.format(end)}"
}
}
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.MutatePriority
@@ -20,6 +21,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -27,6 +29,7 @@ import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.LayoutCoordinates
@@ -35,6 +38,7 @@ import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
@@ -97,11 +101,28 @@ data class TimelineDrag(
* drop lands, so it is dropped once the gesture ends ([finish]). * drop lands, so it is dropped once the gesture ends ([finish]).
*/ */
val edgeOnly: Boolean = false, val edgeOnly: Boolean = false,
/**
* The minute of its day the gutter badge names — the top edge, or for a
* bottom-edge resize the end being dragged (#141).
*/
val markerMin: Int = startMin,
) { ) {
/** What the snap haptics key off: one tick per changed slot, not per frame. */ /**
val slot: Pair<LocalDate, Int> get() = date to startMin * What the snap haptics key off: one tick per changed slot, not per frame.
* Carries the length, which is all a bottom-edge resize changes.
*/
val slot: Triple<LocalDate, Int, Int> get() = Triple(date, startMin, eventSpanMin)
} }
/** The event a long press left selected (#141). */
data class TimelineSelection(
val event: EventInstance,
/** Set from a move or resize of it until the grid shows where it landed. */
val moved: Boolean = false,
/** Selected by letting go of a lifted block, which then keeps that lift as it is. */
val fromLift: Boolean = false,
)
/** /**
* One day column's share of a dragged event, ready to draw. Both halves of an * One day column's share of a dragged event, ready to draw. Both halves of an
* event crossing midnight are previewed, whichever half the finger picked up: * event crossing midnight are previewed, whichever half the finger picked up:
@@ -248,6 +269,56 @@ data class TimelineDrop(
val clipOffsetMin: Int = 0, val clipOffsetMin: Int = 0,
) )
/** Which end of an event a resize handle moves (#141). */
enum class ResizeEdge { Top, Bottom }
/** Where a finished resize asks its event to begin and end. */
data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant)
/**
* The span after dragging [edge] to [edgeMin] in day [edgeDay], in minutes from the
* start day's midnight. Snapped, and at least one snap step long.
*/
internal fun resizedSpan(
edge: ResizeEdge,
startMin: Int,
endMin: Int,
edgeMin: Float,
edgeDay: Int? = null,
): Pair<Int, Int> {
// An edge dragged into a day past the other end changes nothing.
if (edgeDay != null) {
val endDay = (endMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
val wrongWay = when (edge) {
ResizeEdge.Top -> edgeDay > endDay
ResizeEdge.Bottom -> edgeDay < startMin.floorDiv(MINUTES_PER_DAY)
}
if (wrongWay) return startMin to endMin
}
val snapped = (edgeMin / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
return when (edge) {
ResizeEdge.Top -> minOf(snapped, endMin - DRAG_SNAP_MINUTES) to endMin
ResizeEdge.Bottom -> startMin to maxOf(snapped, startMin + DRAG_SNAP_MINUTES)
}
}
/** [minutes] snapped, kept at [held] until three quarters of a step away. */
internal fun stickySnap(minutes: Float, held: Int?): Int {
if (held != null && kotlin.math.abs(minutes - held) < DRAG_SNAP_MINUTES * 0.75f) return held
return (minutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
}
/** How far past its column's side the finger has to go before a resize changes day. */
private const val COLUMN_HYSTERESIS = 0.25f
/** The column under [position], kept at [held] until [COLUMN_HYSTERESIS] into another. */
internal fun stickyColumn(position: Float, held: Int?, lastIndex: Int): Int {
val under = position.toInt().coerceIn(0, lastIndex)
if (held == null) return under
val stays = position >= held - COLUMN_HYSTERESIS && position < held + 1 + COLUMN_HYSTERESIS
return if (stays) held else under
}
/** /**
* The timeline's live geometry, republished on every layout. Plain fields rather * The timeline's live geometry, republished on every layout. Plain fields rather
* than snapshot state: it changes on every scroll frame, and the drag loop reads * than snapshot state: it changes on every scroll frame, and the drag loop reads
@@ -284,8 +355,8 @@ class TimelineGeometry {
} }
/** /**
* Hoisted drag state for one timeline (#68). It lives above the per-page * Hoisted drag state for one timeline (#68). It lives above the pager, so a
* `AnimatedContent`, so a page change mid-drag can't strand a ghost, and the * 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. * block it renders is drawn in an overlay, clear of the day column's clip.
*/ */
@Stable @Stable
@@ -320,10 +391,88 @@ class TimelineDragController {
*/ */
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null) private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
/** The selected event, if any — see [TimelineSelection]. */
var selection: TimelineSelection? by mutableStateOf(null)
private set
fun select(event: EventInstance, moved: Boolean = false, fromLift: Boolean = false) {
if (selection?.event?.instanceId != event.instanceId) selectionCovered = false
selection = TimelineSelection(event, moved, fromLift)
}
fun deselect() {
selection = null
selectionCovered = false
}
/** The events of the blocks on screen that show the selection; none for a while ends it. */
val selectedCandidates = mutableStateListOf<EventInstance>()
fun noteSelectedBlock(event: EventInstance, shown: Boolean) {
if (shown) selectedCandidates.add(event) else selectedCandidates.remove(event)
}
/**
* Once a move of the selected event has settled, point the selection at the
* row it landed as. A new event id wins: a single moved occurrence is written
* as an exception, and another occurrence of its series may share its time.
*/
fun settleMovedSelection() {
val current = selection?.takeIf { it.moved } ?: return
if (isDragging || settling != null) return
val landed = selectedCandidates.filter { it.instanceId != current.event.instanceId }
// Only the original row left: the move was abandoned, and it still stands.
val chosen = landed.firstOrNull { it.eventId != current.event.eventId }
?: landed.firstOrNull() ?: selectedCandidates.firstOrNull() ?: return
selection = current.copy(event = chosen, moved = false)
}
/** Whether the selected block's bottom sits where the action bar would cover it. */
var selectionCovered: Boolean by mutableStateOf(false)
private set
/**
* Whether [block] is the selected event: by instance, by series and start, and
* just after a move by title and times, since a moved occurrence gets new ids.
*/
fun isSelected(block: TimedBlock): Boolean {
val current = selection ?: return false
val selected = current.event
val event = block.event
return event.instanceId == selected.instanceId ||
(event.eventId == selected.eventId && event.start == selected.start) ||
(
current.moved &&
event.calendarId == selected.calendarId &&
event.title.isNotBlank() &&
event.title == selected.title &&
event.start == selected.start &&
event.end == selected.end
)
}
/** Point the selection at [block]'s event, so the bar acts on the row now there. */
fun adoptSelection(block: TimedBlock) {
val current = selection?.takeIf { !it.moved } ?: return
if (current.event != block.event) selection = current.copy(event = block.event)
}
/** Whether a finger is on a block right now — [settling] is not dragging. */ /** Whether a finger is on a block right now — [settling] is not dragging. */
var isDragging: Boolean by mutableStateOf(false) var isDragging: Boolean by mutableStateOf(false)
private set private set
/** Whether the finger has moved since the pickup; only then may the view auto-scroll. */
var pointerMoved: Boolean by mutableStateOf(false)
private set
/** Touch slop, past which the finger counts as having moved. */
var slopPx: Float = 0f
private var pickupPointer = Offset.Zero
/** The end a settling resize asked for, which the grid has to show before it settles. */
private var settledEnd: Instant? = null
/** /**
* Whether the grid itself now draws the settled drop at its landing slot. * Whether the grid itself now draws the settled drop at its landing slot.
* The copy may only be handed back once this is true, or the source ghost * The copy may only be handed back once this is true, or the source ghost
@@ -332,6 +481,10 @@ class TimelineDragController {
var settledOnGrid: Boolean by mutableStateOf(false) var settledOnGrid: Boolean by mutableStateOf(false)
private set private set
/** Whether the settling drop is the selected event, whose block lands still lifted. */
var settlingSelected: Boolean = false
private set
/** /**
* The abandoned-move counter, read at the drop so the mark can't be a * The abandoned-move counter, read at the drop so the mark can't be a
* composition behind the tick it is compared against. * composition behind the tick it is compared against.
@@ -361,7 +514,27 @@ class TimelineDragController {
* The slot the block already occupied when it was picked up — not the same * 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). * as its start, since the target snaps to the grid (09:07 lifts to 09:00).
*/ */
private var originSlot: Pair<LocalDate, Int>? = null private var originSlot: Triple<LocalDate, Int, Int>? = null
/** The edge a resize is dragging, or null for a plain drag (#141). */
private var resizeEdge: ResizeEdge? = null
/** The day the resized event starts on; its edges are minutes from that midnight. */
private var resizeDay: LocalDate? = null
private var resizeStartMin = 0
private var resizeEndMin = 0
/** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */
private var resizeGrabMin = 0f
/** The column the resized edge is in, held until the finger is well into another. */
private var resizeColumn: Int? = null
/** The slot the resized edge sits on, held against a finger trembling between two. */
private var resizeSlot: Int? = null
/** The day, counted from the event's start day, the finger is resizing in. */
private var resizeEdgeDay = 0
fun begin( fun begin(
block: TimedBlock, block: TimedBlock,
@@ -370,6 +543,8 @@ class TimelineDragController {
pointerInRoot: Offset, pointerInRoot: Offset,
blockInRoot: Offset, blockInRoot: Offset,
) { ) {
// The selected event keeps its selection through a drag; any other ends it.
if (!isSelected(block)) deselect()
source = block source = block
this.clipOffsetMin = clipOffsetMin this.clipOffsetMin = clipOffsetMin
this.titleLines = titleLines this.titleLines = titleLines
@@ -378,6 +553,8 @@ class TimelineDragController {
liftedInstanceId = block.event.instanceId liftedInstanceId = block.event.instanceId
grab = pointerInRoot - blockInRoot grab = pointerInRoot - blockInRoot
pointer = pointerInRoot pointer = pointerInRoot
pickupPointer = pointerInRoot
pointerMoved = false
originSlot = null originSlot = null
recompute() recompute()
originSlot = drag?.slot originSlot = drag?.slot
@@ -385,20 +562,28 @@ class TimelineDragController {
fun move(pointerInRoot: Offset) { fun move(pointerInRoot: Offset) {
pointer = pointerInRoot pointer = pointerInRoot
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
recompute() recompute()
} }
fun cancel() { fun cancel() {
resizeEdge = null
resizeDay = null
resizeColumn = null
resizeSlot = null
source = null source = null
clipOffsetMin = 0 clipOffsetMin = 0
titleLines = 0 titleLines = 0
isDragging = false isDragging = false
pointerMoved = false
liftedInstanceId = null liftedInstanceId = null
originSlot = null originSlot = null
drag = null drag = null
settling = null settling = null
settledOnGrid = false settledOnGrid = false
settledOrigin = null settledOrigin = null
settledEnd = null
settlingSelected = false
} }
/** /**
@@ -429,8 +614,11 @@ class TimelineDragController {
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt() val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset) val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
.firstOrNull() ?: return .firstOrNull() ?: return
val end = settledEnd
settledOnGrid = blocks.any { block -> settledOnGrid = blocks.any { block ->
block.startMin == slice.startMin && block.startMin == slice.startMin &&
// A resize may keep its start, so its end has to have landed too.
(end == null || block.event.end == end) &&
( (
block.event.eventId == landed.event.eventId || block.event.eventId == landed.event.eventId ||
( (
@@ -452,6 +640,7 @@ class TimelineDragController {
val clipOffset = clipOffsetMin val clipOffset = clipOffsetMin
cancel() cancel()
if (landed == null || landed.slot == origin) return null if (landed == null || landed.slot == origin) return null
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
// An edge piece is a hint for the finger, not a preview of the landing // An edge piece is a hint for the finger, not a preview of the landing
// slot: the event belongs to a day this timeline has no column for, so // slot: the event belongs to a day this timeline has no column for, so
// [noteGrid] can never seat it and it would sit at the top of today for // [noteGrid] can never seat it and it would sit at the top of today for
@@ -463,6 +652,150 @@ class TimelineDragController {
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset) return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
} }
/**
* Grab one edge of [block] to resize it (#141). The edge follows the finger
* across days as a drag does; the other stays where it is.
*/
fun beginResize(
block: TimedBlock,
edge: ResizeEdge,
zone: TimeZone,
titleLines: Int,
pointerInRoot: Offset,
) {
val start = block.event.start.toLocalDateTime(zone)
val end = block.event.end.toLocalDateTime(zone)
source = block
resizeEdge = edge
resizeDay = start.date
resizeStartMin = start.time.toSecondOfDay() / 60
// Wall clock at both ends, as the drop resolves them (DST-safe).
resizeEndMin = maxOf(
resizeStartMin,
(end.date.toEpochDays() - start.date.toEpochDays()).toInt() * MINUTES_PER_DAY +
end.time.toSecondOfDay() / 60,
)
clipOffsetMin = 0
this.titleLines = titleLines
settling = null
isDragging = true
pointerMoved = true
liftedInstanceId = block.event.instanceId
pointer = pointerInRoot
// Measured from the edge's own column: the handle overhangs into the next.
val edgeDay = if (edge == ResizeEdge.Top) 0 else (resizeEndMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
val edgeIndex = geometry.days.indexOf(start.date.plus(edgeDay, DateTimeUnit.DAY))
resizeColumn = edgeIndex.takeIf { it >= 0 }
?.let { if (geometry.isRtl) geometry.days.lastIndex - it else it }
resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin
originSlot = null
recompute()
originSlot = drag?.slot
}
/**
* End a resize, handing back the event's new span — null when it never
* resolved or came back to the length it started at. Settles as [finish] does.
*/
fun finishResize(zone: TimeZone): TimelineResizeDrop? {
val landed = drag
val origin = originSlot
cancel()
if (landed == null || landed.slot == origin) return null
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start
settledAbandonedMark = abandonedTicks?.value ?: 0
val drop = TimelineResizeDrop(
event = landed.event,
start = wallClockInstant(landed.date, landed.eventStartMin, zone),
end = wallClockInstant(landed.date, landed.eventStartMin + landed.eventSpanMin, zone),
)
settledEnd = drop.end
return drop
}
/**
* Where the finger is, in minutes from the resized event's start day —
* past 1440 in a later column, negative in an earlier one.
*/
private fun pointerResizeMinutes(): Float? {
val grid = geometry.grid?.takeIf { it.isAttached } ?: return null
val day = resizeDay ?: return null
val hourPx = geometry.hourPx
val columnPx = geometry.columnWidthPx
val days = geometry.days
if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return null
val origin = grid.positionInRoot()
val column = stickyColumn(
position = (pointer.x - origin.x) / columnPx,
held = resizeColumn,
lastIndex = days.lastIndex,
)
resizeColumn = column
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
val dayOffset = (days[dayIndex].toEpochDays() - day.toEpochDays()).toInt()
resizeEdgeDay = dayOffset
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
}
private fun recomputeResize(edge: ResizeEdge) {
val block = source ?: return
val day = resizeDay ?: return
val raw = pointerResizeMinutes() ?: return
val slot = stickySnap(raw - resizeGrabMin, resizeSlot)
resizeSlot = slot
val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat(), resizeEdgeDay)
val days = geometry.days
val hourPx = geometry.hourPx
val columnPx = geometry.columnWidthPx
val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return
// Which column the start day sits in, possibly off either end of the view.
val startIndex = (day.toEpochDays() - days.first().toEpochDays()).toInt()
val visible = -startIndex..days.lastIndex - startIndex
val slices = dragSlices(start, end - start, within = visible)
drag = TimelineDrag(
event = block.event,
date = day,
startMin = start,
eventStartMin = start,
eventSpanMin = end - start,
titleLines = titleLines,
pieces = slices.map { slice ->
val dayIndex = startIndex + slice.dayOffset
val col = if (geometry.isRtl) days.lastIndex - dayIndex else dayIndex
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
TimelineDragPiece(
topLeftInRoot = Offset(x = origin.x + col * columnPx, y = origin.y + bounds.top),
sizePx = IntSize(
(columnPx - geometry.columnGapPx).roundToInt(),
bounds.height.roundToInt(),
),
continuesBefore = slice.continuesBefore,
continuesAfter = slice.continuesAfter,
)
},
edgeOnly = slices.isEmpty(),
markerMin = if (edge == ResizeEdge.Top) {
start.mod(MINUTES_PER_DAY)
} else {
end - (end - 1).floorDiv(MINUTES_PER_DAY) * MINUTES_PER_DAY
},
)
}
/** Note whether the action bar would cover the selected block's bottom handle (#141). */
fun noteSelectedBottom(block: LayoutCoordinates, extraPx: Float, clearancePx: Float) {
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return
if (!block.isAttached) return
val port = viewport.boundsInRoot()
val bottom = block.positionInRoot().y + block.size.height + extraPx
val covered = bottom > port.bottom - clearancePx
if (covered != selectionCovered) selectionCovered = covered
}
/** /**
* Stop ghosting the source — the grid draws the drop itself by now — while * Stop ghosting the source — the grid draws the drop itself by now — while
* the copy is still dissolving. Un-ghosting at [release] instead would leave * the copy is still dissolving. Un-ghosting at [release] instead would leave
@@ -477,6 +810,8 @@ class TimelineDragController {
fun release() { fun release() {
settling = null settling = null
settledOnGrid = false settledOnGrid = false
settledEnd = null
settlingSelected = false
handOver() handOver()
} }
@@ -486,6 +821,10 @@ class TimelineDragController {
* no move events while auto-scroll walks the content underneath it. * no move events while auto-scroll walks the content underneath it.
*/ */
fun recompute() { fun recompute() {
resizeEdge?.let {
recomputeResize(it)
return
}
val block = source ?: return val block = source ?: return
val grid = geometry.grid?.takeIf { it.isAttached } ?: return val grid = geometry.grid?.takeIf { it.isAttached } ?: return
val hourPx = geometry.hourPx val hourPx = geometry.hourPx
@@ -577,6 +916,7 @@ fun rememberTimelineDragController(): TimelineDragController {
val density = LocalDensity.current val density = LocalDensity.current
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() } controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() } controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
controller.slopPx = LocalViewConfiguration.current.touchSlop
controller.abandonedTicks = LocalEventMove.current?.abandoned controller.abandonedTicks = LocalEventMove.current?.abandoned
return controller return controller
} }
@@ -613,14 +953,13 @@ fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
* of its own; `toInstant` resolves it forward by the missing hour, and the block * of its own; `toInstant` resolves it forward by the missing hour, and the block
* visibly settles there. * visibly settles there.
*/ */
fun TimelineDrop.startInstant(zone: TimeZone): Instant { fun TimelineDrop.startInstant(zone: TimeZone): Instant =
// Wall clock throughout, and past this day at either end: a block's top edge wallClockInstant(date, startMin - clipOffsetMin, zone)
// can be dragged above its midnight and a tail's below the next one, so the
// start is resolved as a day plus a minute of it rather than as an offset /** [minutes] past [date]'s midnight, either way past it, as wall clock in [zone] (DST-safe). */
// from an instant — which would shift by an hour across a DST boundary. fun wallClockInstant(date: LocalDate, minutes: Int, zone: TimeZone): Instant {
val fromMidnight = startMin - clipOffsetMin val day = date.plus(minutes.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
val day = date.plus(fromMidnight.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY) val minute = minutes.mod(MINUTES_PER_DAY)
val minute = fromMidnight.mod(MINUTES_PER_DAY)
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone) return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
} }
@@ -660,8 +999,10 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
// Both live here rather than beside the controller: they read [drag], which // Both live here rather than beside the controller: they read [drag], which
// changes every frame, and this composable is the one that is meant to. // changes every frame, and this composable is the one that is meant to.
DragSnapHaptics(controller.drag?.slot) DragSnapHaptics(controller.drag?.slot)
LaunchedEffect(controller.isDragging) { // Only once the finger has moved: a long press held near an edge must not
if (controller.isDragging) controller.autoScroll() // scroll the drop target out from under a finger that only means to select.
LaunchedEffect(controller.isDragging && controller.pointerMoved) {
if (controller.isDragging && controller.pointerMoved) controller.autoScroll()
} }
// Hold the landed copy until the write is done — including the time a // Hold the landed copy until the write is done — including the time a
// recurring drop's scope dialog is up — and then until the grid draws the // recurring drop's scope dialog is up — and then until the grid draws the
@@ -693,7 +1034,8 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
// Landed: the copy sinks back to the grid's own plane while the write runs. // Landed: the copy sinks back to the grid's own plane while the write runs.
val landed = controller.drag == null val landed = controller.drag == null
val lift by animateFloatAsState( val lift by animateFloatAsState(
targetValue = if (landed || reduceMotion) 0f else 1f, // A selected event's block is lifted too: sinking first made it shrink, then grow back.
targetValue = if (reduceMotion || (landed && !controller.settlingSelected)) 0f else 1f,
label = "drag-lift", label = "drag-lift",
) )
val copyAlpha by animateFloatAsState( val copyAlpha by animateFloatAsState(
@@ -710,7 +1052,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
val startMin = drag.eventStartMin.mod(MINUTES_PER_DAY) val startMin = drag.eventStartMin.mod(MINUTES_PER_DAY)
val rawEnd = startMin + drag.eventSpanMin val rawEnd = startMin + drag.eventSpanMin
val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd
val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}–" + val range = formatMinuteOfDay(startMin, use24Hour, locale) to
formatMinuteOfDay(endMin, use24Hour, locale) formatMinuteOfDay(endMin, use24Hour, locale)
// The timeline's own bounds. The copy is drawn over the whole calendar // 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 // so no column clip or rounded corner cuts it, but it still belongs to
@@ -764,7 +1106,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
title = title, title = title,
titleLines = drag.titleLines, titleLines = drag.titleLines,
// Once for the whole event: repeated, it would name it per day. // Once for the whole event: repeated, it would name it per day.
label = label.takeIf { index == labelled }, range = range.takeIf { index == labelled },
) )
} }
} }
@@ -784,7 +1126,7 @@ private fun DragCopy(
alpha: Float, alpha: Float,
title: String, title: String,
titleLines: Int, titleLines: Int,
label: String?, range: Pair<String, String>?,
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val width = with(density) { sizePx.width.toDp() } val width = with(density) { sizePx.width.toDp() }
@@ -814,6 +1156,7 @@ private fun DragCopy(
) )
} }
val left = metrics.available - metrics.titleHeight(lines) val left = metrics.available - metrics.titleHeight(lines)
val label = range?.let { rememberBlockTimeRange(it.first, it.second, textWidth) }
val showTime = label != null && left >= metrics.timeLine val showTime = label != null && left >= metrics.timeLine
val timeMaxLines = if (showTime) { val timeMaxLines = if (showTime) {
blockTimeLines(label!!, textWidth, left - metrics.timeLine) blockTimeLines(label!!, textWidth, left - metrics.timeLine)
@@ -879,3 +1222,29 @@ private fun DragCopy(
} }
} }
} }
/** How far a selected block is lifted, 0 to 1 (#141); [snap] takes it over at once. */
@Composable
fun selectionLift(selected: Boolean, snap: Boolean = false): Float {
val reduceMotion = rememberReduceMotion()
val spec = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
val raise = remember { Animatable(if (selected) 1f else 0f) }
LaunchedEffect(selected) {
val target = if (selected) 1f else 0f
if (reduceMotion || (selected && snap)) raise.snapTo(target) else raise.animateTo(target, spec)
}
return raise.value
}
/**
* A selected block raised off the grid, the way a dragged copy is: scaled from
* its top edge so the text doesn't creep, with a shadow in the block's shape.
*/
fun Modifier.selectionLift(raise: Float, shape: Shape): Modifier = graphicsLayer {
transformOrigin = TransformOrigin(0.5f, 0f)
scaleX = 1f + 0.02f * raise
scaleY = 1f + 0.02f * raise
shadowElevation = 6.dp.toPx() * raise
this.shape = shape
clip = false
}
@@ -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. */ /** A height the user pinched to. Build it through [custom], which clamps. */
data class Custom(val hourHeight: Dp) : TimelineScale 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 { companion object {
/** The named steps the settings picker offers, coarse to roomy. */ /** The named steps the settings picker offers, coarse to roomy. */
val presets: List<TimelineScale> = listOf(FitDay, Compact, Regular, Comfortable) 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 is TimelineScale.Custom -> this.hourHeight
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT) .coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
.coerceAtLeast(fillHourHeight(viewportHeight)) .coerceAtLeast(fillHourHeight(viewportHeight))
is TimelineScale.FitHours -> (viewportHeight / (endHour - startHour).coerceIn(1, 24).toFloat())
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
.coerceAtLeast(fillHourHeight(viewportHeight))
} }
/** /**
@@ -134,6 +143,7 @@ val TimelineScale.labelRes: Int
TimelineScale.Regular -> R.string.timeline_scale_regular TimelineScale.Regular -> R.string.timeline_scale_regular
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable TimelineScale.Comfortable -> R.string.timeline_scale_comfortable
is TimelineScale.Custom -> R.string.timeline_scale_custom is TimelineScale.Custom -> R.string.timeline_scale_custom
is TimelineScale.FitHours -> R.string.settings_visible_hours
} }
@get:StringRes @get:StringRes
@@ -144,11 +154,15 @@ val TimelineScale.descriptionRes: Int
TimelineScale.Regular -> R.string.timeline_scale_regular_summary TimelineScale.Regular -> R.string.timeline_scale_regular_summary
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary
is TimelineScale.Custom -> R.string.timeline_scale_custom_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. */ /** Marks a stored custom height; the rest of the value is its dp. */
private const val CUSTOM_PREFIX = "custom:" 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()` * 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 * so R8 can't rename them out from under an existing install — and they match
@@ -171,6 +185,7 @@ private val PRESET_NAMES: Map<TimelineScale, String> = mapOf(
*/ */
fun TimelineScale.storageValue(): String = when (this) { fun TimelineScale.storageValue(): String = when (this) {
is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value
is TimelineScale.FitHours -> "$HOURS_PREFIX$startHour-$endHour"
TimelineScale.FitDay, TimelineScale.FitDay,
TimelineScale.Compact, TimelineScale.Compact,
TimelineScale.Regular, TimelineScale.Regular,
@@ -188,5 +203,11 @@ fun parseTimelineScale(stored: String?): TimelineScale = when {
stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull() stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull()
?.let { TimelineScale.custom(it.dp) } ?.let { TimelineScale.custom(it.dp) }
?: TimelineScale.Regular ?: 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 else -> PRESET_NAMES.entries.firstOrNull { it.value == stored }?.key ?: TimelineScale.Regular
} }
@@ -0,0 +1,111 @@
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 kotlinx.coroutines.withTimeoutOrNull
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 {
// Fitted to the scroll viewport itself: the layout resolves scales
// against the viewport plus the all-day strip, which would push the
// end hour below the screen.
val viewport = snapshotFlow { scrollState.viewportSize }.first { it > 0 }
val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() })
zoom.holdFit(hourHeight)
val hourPx = with(density) { hourHeight.toPx() }
val target = withTimeoutOrNull(FIT_LAYOUT_TIMEOUT_MILLIS) {
snapshotFlow {
startHourScroll(scrollState.maxValue, scrollState.viewportSize, startHour, hourPx)
}.filterNotNull().first()
} ?: (startHour * (scrollState.maxValue + scrollState.viewportSize) / 24)
.coerceIn(0, scrollState.maxValue.coerceAtLeast(0))
scrollState.scrollTo(target)
}
scroll.noonPending = false
}
if (memory != null) {
LaunchedEffect(scrollState, memory) {
snapshotFlow { currentTop - scrollState.value }.collect { memory.midnightY = it }
}
}
return scroll
}
/** How long an opening timeline waits to be laid out at its fitted hours before scrolling anyway. */
private const val FIT_LAYOUT_TIMEOUT_MILLIS = 500L
@@ -49,13 +49,49 @@ class TimelineZoom(
var isPinching: Boolean by mutableStateOf(false) var isPinching: Boolean by mutableStateOf(false)
private set 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 * 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 * 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) { 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() { fun beginPinch() {
@@ -66,9 +102,10 @@ class TimelineZoom(
scale = TimelineScale.custom(hourHeight) scale = TimelineScale.custom(hourHeight)
} }
/** Settles the pinch. With visible hours on it only lasts until the next [open]. */
fun endPinch() { fun endPinch() {
isPinching = false isPinching = false
persist(scale) if (!hours.enabled) persist(scale)
} }
} }
@@ -82,16 +119,18 @@ private val DetachedTimelineZoom by lazy { TimelineZoom(TimelineScale.Regular) {
val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom } val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom }
/** /**
* The app-wide [TimelineZoom], seeded from [stored] and writing settled pinches * The app-wide [TimelineZoom], seeded from [stored] and [hours] and writing
* back through [onPersist]. * settled pinches back through [onPersist].
*/ */
@Composable @Composable
fun rememberTimelineZoom( fun rememberTimelineZoom(
stored: TimelineScale, stored: TimelineScale,
hours: TimelineHours,
onPersist: (TimelineScale) -> Unit, onPersist: (TimelineScale) -> Unit,
): TimelineZoom { ): TimelineZoom {
val persist by rememberUpdatedState(onPersist) val persist by rememberUpdatedState(onPersist)
val zoom = remember { TimelineZoom(stored) { persist(it) } } 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 // Picking a preset in Settings has to reach the timelines, and so does the
// stored value arriving after the first frame. // stored value arriving after the first frame.
LaunchedEffect(stored) { zoom.adopt(stored) } LaunchedEffect(stored) { zoom.adopt(stored) }
@@ -7,9 +7,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -59,7 +59,8 @@ fun TodayAction(show: Boolean, onToday: () -> Unit) {
val description = stringResource(R.string.today_jump_action) val description = stringResource(R.string.today_jump_action)
IconButton( IconButton(
onClick = onToday, onClick = onToday,
modifier = Modifier.semantics { contentDescription = description }, modifier = Modifier
.semantics { contentDescription = description },
) { ) {
val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() } val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() }
Box( Box(
@@ -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,177 @@
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,
/** Read while matching, so a tag can sit out without leaving the modifier chain. */
val enabled: () -> Boolean = { true },
) {
/** This scope with its tags switched off while [enabled] is false. */
fun gated(enabled: () -> Boolean): ViewMorphScope =
ViewMorphScope(shared, visibility, boundsTransform, fadeSpec, clip, stayingPut, enabled)
internal val config = object : SharedTransitionScope.SharedContentConfig {
override val SharedTransitionScope.SharedContentState.isEnabled: Boolean
get() = enabled()
}
}
/**
* 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, morph.config),
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), morph.config),
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,12 +1,17 @@
package de.jeanlucmakiola.calendula.ui.common 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.foundation.layout.padding
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
/** /**
@@ -30,11 +35,23 @@ fun ViewSwitcherPill(
trailingInset: Dp = AppBarSpacing.ContainerTrailingInset, trailingInset: Dp = AppBarSpacing.ContainerTrailingInset,
) { ) {
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
val fadeSpec = rememberCalendarFadeSpec()
FilledTonalButton( FilledTonalButton(
onClick = onCycle, onClick = onCycle,
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
modifier = modifier.padding(end = trailingInset), 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,903 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.animation.AnimatedContent
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.CompositionLocalProvider
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.RectangleShape
import androidx.compose.ui.draw.alpha
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.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.LocalChipGround
import de.jeanlucmakiola.calendula.ui.common.eventPaint
import de.jeanlucmakiola.calendula.ui.common.eventSurface
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.BLOCK_OUTER_INSET
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
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.ChipCuts
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
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.eventTitleOverflow
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.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
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.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
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.hourCellBlockInset
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
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
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */
private val ALL_DAY_ROW_HEIGHT = 20.dp
private val ALL_DAY_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 drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
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,
topBar = {
DayTopBar(
date = date,
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
)
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = !isOnToday && !todayInToolbar,
todayText = stringResource(R.string.day_today_action),
onToday = jumpToToday,
onCreate = { onCreateEvent(date, null) },
)
},
) { innerPadding ->
DayContent(
state = state,
slideDir = slideDir,
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,
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,
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,
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.
// This strip is painted on `surface`, so an outlined chip has to fill
// with that and not with the column's container (#230).
CompositionLocalProvider(
LocalChipGround provides MaterialTheme.colorScheme.surface,
) {
AllDayStrip(
state = state,
height = allDayHeight,
onEventClick = onEventClick,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface),
)
}
// Breathing room between the 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,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
) {
val locale = currentLocale()
val (title, shortTitle) = remember(date, locale, currentYear) {
formatDayTitle(date, locale, currentYear) to
formatDayTitle(date, locale, currentYear, abbreviated = true)
}
TopAppBar(
title = {
CalendarTitleButton(
title = title,
currentDate = date,
onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
)
},
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,
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,
),
)
}
@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 paint = eventPaint(event, dark)
Box(
modifier = modifier
.eventSurface(paint, EventChipShape)
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
) {
val titleOverflow = eventTitleOverflow()
Text(
text = title,
style = MaterialTheme.typography.labelSmall.trimmedLines(),
maxLines = 1,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = paint.titleInk,
fontWeight = paint.titleWeight,
textDecoration = paint.decoration,
)
}
}
@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
// What the hour grid takes off a block's ends, for the floating copy to
// take off its own. Per-end, a cut edge keeps it — that much the drag
// geometry decides itself, from the slice it is drawing.
val blockInsetPx = with(density) {
hourCellBlockInset(LocalShowHourGrid.current, cut = false).toPx()
}
// 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.blockInsetPx = blockInsetPx
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 showHourGrid = LocalShowHourGrid.current
val zone = remember { TimeZone.currentSystemDefault() }
// 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(
// With the grid on, the container colour moves onto the hour cells
// and the column behind them recedes to surface, so an hour boundary
// reads as negative space between two surfaces.
containerColor = if (showHourGrid) {
MaterialTheme.colorScheme.surface
} else {
MaterialTheme.colorScheme.surfaceContainer
},
),
modifier = modifier,
) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
// The hour cells sit over the column background but under the
// event blocks (drawBehind paints before the children).
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer)
// 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,
)
// Where this block is cut at midnight, which decides both
// its shape and the half-gap it gives up to the hour cells.
val cuts = remember(block, date, zone) {
timedBlockCuts(
block.continuesBefore(date, zone),
block.continuesAfter(date, zone),
)
}
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
val blockHeight = (place.height - topInset - bottomInset)
.coerceAtLeast(0.dp)
EventBlock(
block = block,
dark = dark,
height = blockHeight,
width = place.width,
date = date,
cuts = cuts,
topInset = topInset,
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
modifier = Modifier
.offset(x = place.x, y = place.y + topInset)
.width(place.width)
.height(blockHeight)
.padding(horizontal = BLOCK_OUTER_INSET),
)
}
}
// 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,
width: Dp,
date: LocalDate,
cuts: ChipCuts,
topInset: Dp,
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 metrics = rememberBlockTextMetrics(height)
// 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.
// Height alone decides: a duration threshold would keep hiding the time on a
// half-hour block the user has pinched open to three times the room it needs.
val showTime = metrics.available >= metrics.titleLine + metrics.timeLine
val showTitle = metrics.fitsTitle
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
// Only lines the block can actually draw: a block too short for the time is
// too short for a second title line too, and asking for one served a sliced
// one — as well as handing the drag copy a count it couldn't honour, so the
// title re-wrapped the moment the block was lifted (#267).
val titleBudget = metrics.titleBudget(metrics.available).coerceAtLeast(1)
val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2)
// On a day column — wide enough for "09:30–11:00" several times over — the
// range never needs the second line, until lanes cut the column down.
val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
if (showTime) metrics.timeLine else 0.dp
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
val paint = eventPaint(block.event, dark)
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(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
val topInsetPx = with(density) { topInset.toPx() }
val dragModifier = rememberEventDragSource(
enabled = draggable,
key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ ->
dragController.begin(
block = block,
clipOffsetMin = clipOffset,
titleLines = if (showTitle) titleMaxLines else 0,
pointerInRoot = pointer,
// Measured off the block's placement rather than off where the
// hour grid seats it: the copy is drawn from that placement and
// re-seated the same half-gap down (#267).
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
)
},
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)
.eventSurface(paint, shape, cuts)
.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 = BLOCK_TEXT_PADDING, vertical = metrics.inset)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
},
) {
Column {
if (showTitle) {
BlockTitle(
title = title,
maxLines = titleMaxLines,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
)
}
if (showTime) {
BlockTimeLabel(
label = timeLabel,
color = paint.secondaryInk,
maxLines = timeMaxLines,
)
}
}
}
}
@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 hourHeight = scale.hourHeight(maxHeight)
val totalHeight = hourHeight * 24
// The skeleton wears the loaded column's own ground, so the arrival of
// the real day doesn't flash a solid block into a gapped grid.
val showHourGrid = LocalShowHourGrid.current
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
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(
if (showHourGrid) MaterialTheme.colorScheme.surface
else MaterialTheme.colorScheme.surfaceContainer,
)
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer),
)
}
}
}
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
formatMinuteOfDay(min, is24Hour, locale)
/** [abbreviated] drops the weekday, leaving the date itself (#165). */
private fun formatDayTitle(
date: LocalDate,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String =
formatCalendarTitle(
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
locale = locale,
currentYear = currentYear,
skeleton = if (abbreviated) "dMMM" else "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,110 +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.domain.calendarListFailure
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 {
calendarListFailure(calendars)?.let { return DayUiState.Failure(it) }
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,63 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.foundation.rememberScrollState
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,
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),
)
}
@@ -1,13 +1,9 @@
package de.jeanlucmakiola.calendula.ui.detail package de.jeanlucmakiola.calendula.ui.detail
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -41,7 +37,6 @@ import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -51,7 +46,6 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -67,7 +61,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
@@ -90,13 +83,15 @@ import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.TimeZoneOption import de.jeanlucmakiola.calendula.domain.TimeZoneOption
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
import de.jeanlucmakiola.calendula.ui.common.FieldCopier import de.jeanlucmakiola.calendula.ui.common.FieldCopier
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
@@ -104,7 +99,6 @@ import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
@@ -140,6 +134,7 @@ fun EventDetailScreen(
onBack: () -> Unit, onBack: () -> Unit,
onEdit: () -> Unit, onEdit: () -> Unit,
onDuplicate: (EventForm) -> Unit, onDuplicate: (EventForm) -> Unit,
visible: Boolean = true,
viewModel: EventDetailViewModel = hiltViewModel(), viewModel: EventDetailViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(eventId, beginMillis, endMillis) { LaunchedEffect(eventId, beginMillis, endMillis) {
@@ -161,43 +156,15 @@ fun EventDetailScreen(
val onShareClick = { val onShareClick = {
scope.launch { scope.launch {
val uri = viewModel.shareUri() val uri = viewModel.shareUri()
val sent = uri != null && runCatching { val sent = uri != null && context.sendEventShare(uri, shareChooserTitle)
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/calendar"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(send, shareChooserTitle))
}.isSuccess
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage) if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
} }
Unit Unit
} }
// v1.0 installs only hold READ_CALENDAR; the first write asks for the // v1.0 installs only hold READ_CALENDAR; the first write asks for the
// upgrade in place. Granting continues straight into the tapped action — // upgrade in place and continues straight into the tapped action.
// edit, delete, or duplicate — held here until the result comes back. val requireWrite = rememberWriteGate()
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
val writePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) pendingWrite?.invoke()
pendingWrite = null
}
val hasWritePermission = {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
}
val requireWrite: (() -> Unit) -> Unit = { action ->
if (hasWritePermission()) {
action()
} else {
pendingWrite = action
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
val onDeleteClick = { requireWrite { showDeleteDialog = true } } val onDeleteClick = { requireWrite { showDeleteDialog = true } }
val onEditClick = { requireWrite(onEdit) } val onEditClick = { requireWrite(onEdit) }
// Duplicate reads the loaded detail into a create form and hands it up; the // Duplicate reads the loaded detail into a create form and hands it up; the
@@ -227,7 +194,7 @@ fun EventDetailScreen(
} }
Scaffold( Scaffold(
modifier = Modifier.predictiveBack(onBack = onBack), modifier = Modifier.predictiveBack(onBack = onBack, visible = visible),
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -321,70 +288,6 @@ fun EventDetailScreen(
} }
} }
/**
* Delete confirmation. Recurring events choose between cancelling just the
* tapped occurrence (default), truncating the series from it onwards, and
* removing the whole series.
*/
@Composable
private fun DeleteEventDialog(
isRecurring: Boolean,
onConfirm: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
) {
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(
if (isRecurring) R.string.event_delete_recurring_title
else R.string.event_delete_title,
),
)
},
text = {
if (isRecurring) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { scope = RecurringWriteScope.ThisEvent },
selected = scope == RecurringWriteScope.ThisEvent,
)
OptionCard(
label = stringResource(R.string.event_delete_option_following),
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
selected = scope == RecurringWriteScope.ThisAndFollowing,
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { scope = RecurringWriteScope.AllEvents },
selected = scope == RecurringWriteScope.AllEvents,
)
}
} else {
Text(stringResource(R.string.event_delete_body))
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
) {
Text(
text = stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
@Composable @Composable
private fun EventDetailContent( private fun EventDetailContent(
state: EventDetailUiState.Success, state: EventDetailUiState.Success,
@@ -533,7 +436,7 @@ private fun EventDetailContent(
) )
} }
// Location (conditional, tap → maps). // Location (conditional). URLs open in the browser, the rest in maps.
instance.location?.takeIf { it.isNotBlank() }?.let { location -> instance.location?.takeIf { it.isNotBlank() }?.let { location ->
val context = LocalContext.current val context = LocalContext.current
val locationLabel = stringResource(R.string.event_detail_location) val locationLabel = stringResource(R.string.event_detail_location)
@@ -543,7 +446,7 @@ private fun EventDetailContent(
iconContentDescription = locationLabel, iconContentDescription = locationLabel,
) { ) {
Text( Text(
text = location, text = linkifyUrls(location, MaterialTheme.colorScheme.primary),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier modifier = Modifier
@@ -553,6 +456,7 @@ private fun EventDetailContent(
text = location, text = location,
copy = copyField, copy = copyField,
onTap = { openInMaps(context, location) }, onTap = { openInMaps(context, location) },
onTapLabel = stringResource(R.string.event_detail_open_in_maps),
) )
.padding(vertical = 2.dp), .padding(vertical = 2.dp),
) )
@@ -845,21 +749,23 @@ private fun foreignTimeZone(
) )
} }
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */ /** Wrap http(s) URLs in [text] as links tinted [linkColor] that open in the browser. */
@Composable @Composable
private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString = remember(text, linkColor) { private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString {
val regex = Regex("""https?://\S+""") val context = LocalContext.current
val styles = TextLinkStyles( return remember(text, linkColor, context) {
style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline), val styles = TextLinkStyles(
) style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
buildAnnotatedString { )
append(text) buildAnnotatedString {
for (match in regex.findAll(text)) { append(text)
// Trim trailing punctuation that commonly abuts a URL in prose. for (link in findWebLinks(text)) {
val raw = match.value addLink(
val url = raw.trimEnd('.', ',', ';', ':', '!', '?', ')', ']', '"', '\'') LinkAnnotation.Url(link.url, styles) { openWebLink(context, link.url) },
val end = match.range.first + url.length link.start,
addLink(LinkAnnotation.Url(url, styles), match.range.first, end) link.end,
)
}
} }
} }
} }
@@ -914,21 +820,19 @@ private fun formatWhen(
private fun Instant.toJavaLocalDateTime(zid: ZoneId): java.time.LocalDateTime = private fun Instant.toJavaLocalDateTime(zid: ZoneId): java.time.LocalDateTime =
java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(toEpochMilliseconds()), zid) java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(toEpochMilliseconds()), zid)
/** Open [url] in a browsable app, or say so when none can take it. */
private fun openWebLink(context: Context, url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE)
if (runCatching { context.startActivity(intent) }.isFailure) {
Toast.makeText(context, R.string.link_open_failed, Toast.LENGTH_SHORT).show()
}
}
/** Open a maps intent for [query]; fall back to a web maps URL if no app handles geo:. */ /** Open a maps intent for [query]; fall back to a web maps URL if no app handles geo:. */
private fun openInMaps(context: Context, query: String) { private fun openInMaps(context: Context, query: String) {
val encoded = Uri.encode(query) val encoded = Uri.encode(query)
val geo = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$encoded")) val geo = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$encoded"))
try { if (runCatching { context.startActivity(geo) }.isFailure) {
context.startActivity(geo) openWebLink(context, "https://www.google.com/maps/search/?api=1&query=$encoded")
} catch (e: ActivityNotFoundException) {
val web = Intent(
Intent.ACTION_VIEW,
Uri.parse("https://www.google.com/maps/search/?api=1&query=$encoded"),
)
try {
context.startActivity(web)
} catch (e2: ActivityNotFoundException) {
// No browser either — nothing sensible to do; swallow.
}
} }
} }
@@ -11,12 +11,11 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import de.jeanlucmakiola.calendula.domain.toDuplicateForm
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent import de.jeanlucmakiola.calendula.ui.common.deleteEvent
import de.jeanlucmakiola.calendula.domain.toEditForm import de.jeanlucmakiola.calendula.ui.common.stageEventShare
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.time.Clock
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -113,14 +112,7 @@ class EventDetailViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
_deleteState.value = DeleteUiState.Deleting _deleteState.value = DeleteUiState.Deleting
_deleteState.value = try { _deleteState.value = try {
when (scope) { repository.deleteEvent(target.eventId, target.beginMillis, scope)
RecurringWriteScope.AllEvents ->
repository.deleteEvent(target.eventId)
RecurringWriteScope.ThisEvent ->
repository.deleteOccurrence(target.eventId, target.beginMillis)
RecurringWriteScope.ThisAndFollowing ->
repository.deleteEventFromOccurrence(target.eventId, target.beginMillis)
}
DeleteUiState.Deleted DeleteUiState.Deleted
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
@@ -144,40 +136,16 @@ class EventDetailViewModel @Inject constructor(
*/ */
suspend fun shareUri(): Uri? { suspend fun shareUri(): Uri? {
val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null
return runCatching { return withContext(io) { icsExporter.stageEventShare(detail) }
withContext(io) {
val ics = IcsWriter().writeCalendar(
events = listOf(detail.toShareIcsEvent()),
dtStamp = Clock.System.now(),
)
icsExporter.stageShareFile(shareFileName(detail.instance.title), ics)
}
}.getOrNull()
} }
/** /**
* Build a create form seeded from the open event so the user can save a * A create form seeded from the open event, to save a copy as a new event
* copy as a new, independent event (#52). Returns null when nothing is * (#52) — see [toDuplicateForm]. Null when nothing is loaded.
* loaded.
*
* The recurrence is dropped — a duplicate is a single event; anyone who
* wants a series can re-add one in the edit form that opens. The source
* calendar is kept only when it's writable, otherwise the id is cleared so
* the create resolves to the last-used/first-writable calendar — which lets
* a read-only event (a WebCal subscription, a birthday) be copied into an
* editable calendar. The occurrence's own times carry over unchanged.
*/ */
fun duplicateForm(): EventForm? { fun duplicateForm(): EventForm? {
val loaded = state.value as? EventDetailUiState.Success ?: return null val loaded = state.value as? EventDetailUiState.Success ?: return null
val detail = loaded.detail return loaded.detail.toDuplicateForm(loaded.canModify, TimeZone.currentSystemDefault())
return detail.toEditForm(
beginMillis = detail.instance.start.toEpochMilliseconds(),
endMillis = detail.instance.end.toEpochMilliseconds(),
zone = TimeZone.currentSystemDefault(),
).copy(
rrule = null,
calendarId = detail.instance.calendarId.takeIf { loaded.canModify },
)
} }
private suspend fun loadDetail(target: Target): EventDetailUiState = try { private suspend fun loadDetail(target: Target): EventDetailUiState = try {
@@ -228,13 +196,3 @@ class EventDetailViewModel @Inject constructor(
const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE
} }
} }
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
private fun shareFileName(title: String): String {
val base = title.trim()
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
.replace(' ', '_')
.take(40)
.ifBlank { "event" }
return "$base.ics"
}
@@ -0,0 +1,18 @@
package de.jeanlucmakiola.calendula.ui.detail
/** An http(s) URL found in free text, spanning [start] until [end] (exclusive). */
internal data class WebLink(val start: Int, val end: Int, val url: String)
private val webLinkRegex = Regex("""https?://[^\s<>"]+""", RegexOption.IGNORE_CASE)
/**
* Find the http(s) URLs in [text]. Trailing prose punctuation is left out of
* each match, and the scheme is lower-cased so intent filters match it.
*/
internal fun findWebLinks(text: String): List<WebLink> = webLinkRegex.findAll(text).mapNotNull { match ->
val raw = match.value.trimEnd('.', ',', ';', ':', '!', '?', ')', ']', '\'')
val schemeEnd = raw.indexOf(':')
if (raw.length <= schemeEnd + 3) return@mapNotNull null
val url = raw.substring(0, schemeEnd).lowercase() + raw.substring(schemeEnd)
WebLink(match.range.first, match.range.first + raw.length, url)
}.toList()
@@ -99,6 +99,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -203,6 +204,7 @@ fun EventEditScreen(
initialForm: EventForm? = null, initialForm: EventForm? = null,
initialFormSource: ImportSource = ImportSource.File, initialFormSource: ImportSource = ImportSource.File,
onManageCalendars: (() -> Unit)? = null, onManageCalendars: (() -> Unit)? = null,
visible: Boolean = true,
viewModel: EventEditViewModel = hiltViewModel(), viewModel: EventEditViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(initialDateIso, editKey, initialForm) { LaunchedEffect(initialDateIso, editKey, initialForm) {
@@ -285,7 +287,7 @@ fun EventEditScreen(
} }
Scaffold( Scaffold(
modifier = Modifier.predictiveBack(onBack = close), modifier = Modifier.predictiveBack(onBack = close, visible = visible),
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -608,6 +610,11 @@ private fun EventEditContent(
.padding(vertical = 4.dp) .padding(vertical = 4.dp)
.focusRequester(titleFocusRequester), .focusRequester(titleFocusRequester),
) )
FieldLengthCounter(
length = form.title.length,
max = MAX_SINGLE_LINE_FIELD,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
Box( Box(
modifier = Modifier modifier = Modifier
@@ -797,25 +804,31 @@ private fun EventEditContent(
iconAtTop = true, iconAtTop = true,
) { ) {
Row(verticalAlignment = Alignment.Top) { Row(verticalAlignment = Alignment.Top) {
InlineField( Column(modifier = Modifier.weight(1f)) {
value = form.location, InlineField(
onValueChange = viewModel::setLocation, value = form.location,
placeholder = stringResource(R.string.event_detail_location), onValueChange = viewModel::setLocation,
// A location is as often a meeting URL as an address, placeholder = stringResource(R.string.event_detail_location),
// and "Zoom.us/j/123" reads wrong (#146). // A location is as often a meeting URL as an address,
capitalization = KeyboardCapitalization.None, // and "Zoom.us/j/123" reads wrong (#146).
// Multi-line so a long address or meeting URL wraps and capitalization = KeyboardCapitalization.None,
// the card grows instead of scrolling off one line // Multi-line so a long address or meeting URL wraps and
// (#273). The field holds no newlines of its own, so // the card grows instead of scrolling off one line
// the IME's action key closes the keyboard rather than // (#273). The field holds no newlines of its own, so
// inserting a break setLocation would only fold away. // the IME's action key closes the keyboard rather than
singleLine = false, // inserting a break setLocation would only fold away.
imeAction = ImeAction.Done, singleLine = false,
modifier = Modifier imeAction = ImeAction.Done,
.weight(1f) modifier = Modifier
.animateContentSizeMotion() .fillMaxWidth()
.padding(vertical = 4.dp), .animateContentSizeMotion()
) .padding(vertical = 4.dp),
)
FieldLengthCounter(
length = form.location.length,
max = MAX_SINGLE_LINE_FIELD,
)
}
// The box is exactly one text line tall, so the button // The box is exactly one text line tall, so the button
// centres on the first line without making a single-line // centres on the first line without making a single-line
// card taller than its text; requiredSize keeps the // card taller than its text; requiredSize keeps the
@@ -2290,6 +2303,28 @@ private fun InlineField(
) )
} }
/**
* A right-aligned "n/max" counter for a capped [InlineField] (#331), hidden
* until [length] gets close enough to [max] to matter — nobody needs to see
* "12/255" while typing a short title.
*/
@Composable
private fun FieldLengthCounter(length: Int, max: Int, modifier: Modifier = Modifier) {
AnimatedVisibility(visible = length >= max - 55, modifier = modifier) {
Text(
text = "$length/$max",
style = MaterialTheme.typography.bodySmall,
color = if (length >= max) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth(),
)
}
}
/** /**
* Height of one [InlineField] line: the resolved [TextStyle.lineHeight] plus the * 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 * field's 4dp vertical padding. In dp, so it tracks the user's font scale
@@ -62,6 +62,26 @@ private const val TAG = "EventEdit"
/** Any line break, with the whitespace around it, as one match. */ /** Any line break, with the whitespace around it, as one match. */
private val LINE_BREAK = Regex("""[ \t]*\R[ \t]*""") private val LINE_BREAK = Regex("""[ \t]*\R[ \t]*""")
/**
* Title and location are single-line provider columns that several CalDAV
* servers (Open-Xchange among them, #331) store as `VARCHAR(255)` and
* silently truncate past that on sync, with no error surfaced back to the
* app. 255 is the common floor, so it's what Calendula enforces up front.
*/
internal const val MAX_SINGLE_LINE_FIELD = 255
/**
* Caps [new] at [max] chars, but only while growing past it — an [old] value
* that already exceeds [max] (imported, or synced in from elsewhere) may
* still shrink freely, it just can't grow any further. Never splits a
* surrogate pair at the cut point.
*/
internal fun capLength(new: String, old: String, max: Int = MAX_SINGLE_LINE_FIELD): String = when {
new.length <= max || new.length <= old.length -> new
old.length >= max -> old
else -> new.take(max).let { if (it.isNotEmpty() && it.last().isHighSurrogate()) it.dropLast(1) else it }
}
/** /**
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources * Where a prefilled [EventEditViewModel.openImported] form came from. The sources
* want different reminder handling (#49), and differ in whether they own the * want different reminder handling (#49), and differ in whether they own the
@@ -533,13 +553,17 @@ class EventEditViewModel @Inject constructor(
// The title field wraps (multi-line) so long titles stay visible (#33), but // The title field wraps (multi-line) so long titles stay visible (#33), but
// a title is one logical line: drop any newline the IME's Enter key or a // a title is one logical line: drop any newline the IME's Enter key or a
// paste would introduce, so it never reaches the provider's TITLE column. // paste would introduce, so it never reaches the provider's TITLE column.
fun setTitle(value: String) = fun setTitle(value: String) = update {
update { it.copy(title = value.replace("\n", "").replace("\r", "")) } val cleaned = value.replace("\n", "").replace("\r", "")
it.copy(title = capLength(cleaned, it.title))
}
// The location wraps too (#273) but is likewise one logical line. A pasted // 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 // multi-line address joins the way the contact picker's does (#146), so it
// never runs together into "12 Main St10115 Berlin". // never runs together into "12 Main St10115 Berlin".
fun setLocation(value: String) = fun setLocation(value: String) = update {
update { it.copy(location = value.replace(LINE_BREAK, ", ")) } val cleaned = value.replace(LINE_BREAK, ", ")
it.copy(location = capLength(cleaned, it.location))
}
fun setDescription(value: String) = update { it.copy(description = value) } fun setDescription(value: String) = update { it.copy(description = value) }
fun setAllDay(value: Boolean) { fun setAllDay(value: Boolean) {
// Going all-day drops any pinned zone: the times become bare dates that // Going all-day drops any pinned zone: the times become bare dates that
@@ -78,6 +78,7 @@ fun ImportScreen(
onOpenSingle: (EventForm) -> Unit, onOpenSingle: (EventForm) -> Unit,
forceMany: Boolean = false, forceMany: Boolean = false,
onManageCalendars: (() -> Unit)? = null, onManageCalendars: (() -> Unit)? = null,
visible: Boolean = true,
viewModel: ImportViewModel = hiltViewModel(), viewModel: ImportViewModel = hiltViewModel(),
) { ) {
// hiltViewModel() resolves to the Activity's store and is retained across // hiltViewModel() resolves to the Activity's store and is retained across
@@ -103,7 +104,7 @@ fun ImportScreen(
Scaffold( Scaffold(
modifier = Modifier modifier = Modifier
.predictiveBack(onBack = onClose) .predictiveBack(onBack = onClose, visible = visible)
.fillMaxSize(), .fillMaxSize(),
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -23,13 +23,14 @@ import de.jeanlucmakiola.floret.components.positionOf
* what the *recipient of the binary* is told. Each row opens the project's source. * what the *recipient of the binary* is told. Each row opens the project's source.
*/ */
@Composable @Composable
internal fun LicencesScreen(onBack: () -> Unit) { internal fun LicencesScreen(onBack: () -> Unit, visible: Boolean = true) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
CollapsingScaffold( CollapsingScaffold(
title = stringResource(R.string.licences_title), title = stringResource(R.string.licences_title),
onBack = onBack, onBack = onBack,
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
Text( Text(
stringResource(R.string.licences_intro), stringResource(R.string.licences_intro),
File diff suppressed because it is too large Load Diff
@@ -7,11 +7,15 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import de.jeanlucmakiola.calendula.domain.isDeclined 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.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
@@ -25,6 +29,9 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn 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.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
@@ -40,15 +47,18 @@ import kotlinx.datetime.minus
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject 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) @OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel @HiltViewModel
class MonthViewModel @Inject constructor( class MonthViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs, settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
@@ -82,11 +92,37 @@ class MonthViewModel @Inject constructor(
) )
private val todayDate: LocalDate private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date get() = currentDate()
private var lastToday = todayDate
/** Today, kept current across midnight so a grid left open doesn't mark yesterday (#373). */
val today: StateFlow<LocalDate> = todaySource.today
.onEach { date ->
val old = lastToday
lastToday = date
if (old != date) followToday(old, date)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month)) private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
val month: StateFlow<YearMonth> = _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> = val state: StateFlow<MonthUiState> =
combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) } combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) }
.flatMapLatest { (ym, ws, style) -> .flatMapLatest { (ym, ws, style) ->
@@ -94,22 +130,49 @@ class MonthViewModel @Inject constructor(
// Split draw, so under a scrolling style this query is a month // Split draw, so under a scrolling style this query is a month
// of provider work for a view that isn't on screen. // of provider work for a view that isn't on screen.
if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading) if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading)
val range = monthGridRange(ym, ws, zone) month(ym, ws).filterNot { it is MonthUiState.Loading }
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(ym, ws, calendars, instances)
}
} }
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L), started = SharingStarted.WhileSubscribed(5_000L),
initialValue = MonthUiState.Loading, 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)),
today,
) { calendars, instances, today ->
buildState(ym, ws, calendars, instances, today)
}
.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) ---------------------------------- // --- Continuous + Dense styles (#38) ----------------------------------
// //
// These scroll through every month there is, so they can't load "a month" — // These scroll through every month there is, so they can't load "a month" —
@@ -162,8 +225,9 @@ class MonthViewModel @Inject constructor(
combine( combine(
repository.calendars(), repository.calendars(),
repository.instances(range), repository.instances(range),
) { calendars, instances -> today,
buildContinuousState(window, ws, style, calendars, instances) ) { calendars, instances, today ->
buildContinuousState(window, ws, style, calendars, instances, today)
} }
} }
// A load landing is the cue to reach further out. Widening from here // A load landing is the cue to reach further out. Widening from here
@@ -208,6 +272,7 @@ class MonthViewModel @Inject constructor(
style: MonthViewStyle, style: MonthViewStyle,
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
instances: List<EventInstance>, instances: List<EventInstance>,
today: LocalDate,
): ContinuousMonthUiState { ): ContinuousMonthUiState {
calendarListFailure(calendars)?.let { return ContinuousMonthUiState.Failure(it) } calendarListFailure(calendars)?.let { return ContinuousMonthUiState.Failure(it) }
// Bucketed once for the whole window: a row then takes the handful of // Bucketed once for the whole window: a row then takes the handful of
@@ -234,7 +299,7 @@ class MonthViewModel @Inject constructor(
emptyMap() emptyMap()
} }
return ContinuousMonthUiState.Success( return ContinuousMonthUiState.Success(
today = todayDate, today = today,
monthsByIndex = months, monthsByIndex = months,
weeksByIndex = weeks, weeksByIndex = weeks,
weekStart = weekStart, weekStart = weekStart,
@@ -244,12 +309,10 @@ class MonthViewModel @Inject constructor(
// --- Split style (#53) ------------------------------------------------ // --- Split style (#53) ------------------------------------------------
// //
// The first selected-day concept in the app: the other views drill straight // Null means the full-month agenda; a date narrows the pane to that day.
// into a date, while the split style keeps one selected and lists it below
// the grid.
private val _selectedDate = MutableStateFlow(todayDate) private val _selectedDate = MutableStateFlow<LocalDate?>(null)
val selectedDate: StateFlow<LocalDate> = _selectedDate val selectedDate: StateFlow<LocalDate?> = _selectedDate
/** /**
* Select [date], following it to its month if it sits in the grid's leading * Select [date], following it to its month if it sits in the grid's leading
@@ -257,33 +320,45 @@ class MonthViewModel @Inject constructor(
* silently list a date the grid isn't pointing at. * silently list a date the grid isn't pointing at.
*/ */
fun selectDate(date: LocalDate) { fun selectDate(date: LocalDate) {
_selectedDate.value = date
val target = YearMonth(date.year, date.month) 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 if (target != _month.value) _month.value = target
} }
/** fun clearSelection() { _selectedDate.value = null }
* 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 /** A pane swipe walks days when filtered, months when showing the agenda. */
* shows. 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() { private fun realignSelection() {
_selectedDate.value = selectionForMonth(_month.value, todayDate) _selectedDate.value = null
}
fun goToPrev() {
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
realignSelection()
}
fun goToNext() {
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
realignSelection()
} }
fun goToToday() { fun goToToday() {
_month.value = YearMonth(todayDate.year, todayDate.month) _month.value = YearMonth(todayDate.year, todayDate.month)
_selectedDate.value = todayDate _selectedDate.value = null
}
/**
* The date rolled over from [old] to [new]. A paged grid still on [old]'s
* month moves on to [new]'s and drops the day picked yesterday; one browsed
* elsewhere stays put. The scrolling styles only repaint the today marker.
*/
private fun followToday(old: LocalDate, new: LocalDate) {
if (viewStyle.value.isScrolling) return
if (_month.value != YearMonth(old.year, old.month)) return
_month.value = YearMonth(new.year, new.month)
_selectedDate.value = null
} }
/** /**
@@ -292,13 +367,12 @@ class MonthViewModel @Inject constructor(
* otherwise sit wherever paged navigation last left it (today's month, on a * 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 * 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 * scrolling style's list, would jump there instead of holding position. The
* selection is realigned alongside it so that landing on the Split style * selection is cleared alongside it so Split opens the visible month's agenda.
* shows a live day in the month on screen rather than a stale, off-month one.
*/ */
fun syncScrollMonth(ym: YearMonth) { fun syncScrollMonth(ym: YearMonth) {
if (_month.value == ym) return if (_month.value == ym) return
_month.value = ym _month.value = ym
_selectedDate.value = selectionForMonth(ym, todayDate) _selectedDate.value = null
} }
/** Jump to the month containing [date] (drawer jump-to-date). */ /** Jump to the month containing [date] (drawer jump-to-date). */
@@ -312,12 +386,13 @@ class MonthViewModel @Inject constructor(
weekStart: DayOfWeek, weekStart: DayOfWeek,
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
instances: List<EventInstance>, instances: List<EventInstance>,
today: LocalDate,
): MonthUiState { ): MonthUiState {
calendarListFailure(calendars)?.let { return MonthUiState.Failure(it) } calendarListFailure(calendars)?.let { return MonthUiState.Failure(it) }
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone) val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
return MonthUiState.Success( return MonthUiState.Success(
month = ym, month = ym,
today = todayDate, today = today,
weeks = weeks, weeks = weeks,
instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone), instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone),
zone = zone, zone = zone,
@@ -461,11 +536,7 @@ internal fun instancesByDay(
days.associateWith { day -> days.associateWith { day ->
instances instances
.filter { it.coversDay(day, zone) } .filter { it.coversDay(day, zone) }
.sortedWith( .sortedWith(eventDisplayComparator(zone))
compareBy<EventInstance> { it.isDeclined }
.thenByDescending { it.isAllDay }
.thenBy { it.start },
)
} }
/** /**
@@ -506,11 +577,10 @@ private const val WINDOW_EDGE = 2
internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0) 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]: * First day shown in the split agenda: today in the current month, otherwise
* [today] when the month holds it, otherwise the 1st. Pure so the rule can be * the first of the visible month.
* tested without standing up a view model and a provider behind it.
*/ */
internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate = internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate =
if (YearMonth(today.year, today.month) == month) { if (YearMonth(today.year, today.month) == month) {
today today
} else { } else {
@@ -18,11 +18,11 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS 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.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
import de.jeanlucmakiola.calendula.ui.common.icon import de.jeanlucmakiola.calendula.ui.common.icon
import de.jeanlucmakiola.calendula.ui.common.labelRes import de.jeanlucmakiola.calendula.ui.common.label
import de.jeanlucmakiola.calendula.ui.day.DayViewPreview
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.month.descriptionRes import de.jeanlucmakiola.calendula.ui.month.descriptionRes
@@ -67,14 +67,19 @@ internal fun ViewStep(
height = PREVIEW_HEIGHT, height = PREVIEW_HEIGHT,
) )
CalendarView.Week -> WeekViewPreview(weekStart = weekStart, 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) CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
} }
} }
IMPLEMENTED_VIEWS.forEachIndexed { index, view -> IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
val isSelected = view == choice.defaultView val isSelected = view == choice.defaultView
GroupedRow( GroupedRow(
title = stringResource(view.labelRes), title = view.label(),
position = positionOf(index, IMPLEMENTED_VIEWS.size), position = positionOf(index, IMPLEMENTED_VIEWS.size),
selected = isSelected, selected = isSelected,
leading = { leading = {
@@ -126,6 +126,7 @@ fun SearchScreen(
onBack: () -> Unit, onBack: () -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
visible: Boolean = true,
viewModel: SearchViewModel = hiltViewModel(), viewModel: SearchViewModel = hiltViewModel(),
) { ) {
val query by viewModel.query.collectAsStateWithLifecycle() val query by viewModel.query.collectAsStateWithLifecycle()
@@ -184,7 +185,7 @@ fun SearchScreen(
BackHandler(enabled = inSelection) { viewModel.clearSelection() } BackHandler(enabled = inSelection) { viewModel.clearSelection() }
Scaffold( Scaffold(
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection), modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection, visible = visible),
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
topBar = { topBar = {
SearchTopBar( SearchTopBar(
@@ -76,6 +76,7 @@ internal fun AppearanceScreen(
state: SettingsUiState, state: SettingsUiState,
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit, onBack: () -> Unit,
visible: Boolean = true,
) { ) {
var showTheme by remember { mutableStateOf(false) } var showTheme by remember { mutableStateOf(false) }
var showBrandFont by remember { mutableStateOf(false) } var showBrandFont by remember { mutableStateOf(false) }
@@ -96,6 +97,7 @@ internal fun AppearanceScreen(
title = stringResource(R.string.settings_section_appearance), title = stringResource(R.string.settings_section_appearance),
onBack = onBack, onBack = onBack,
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
// Theme & colour // Theme & colour
GroupedRow( GroupedRow(
@@ -41,6 +41,7 @@ internal fun EventFormScreen(
state: SettingsUiState, state: SettingsUiState,
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit, onBack: () -> Unit,
visible: Boolean = true,
) { ) {
var showDefaultDuration by remember { mutableStateOf(false) } var showDefaultDuration by remember { mutableStateOf(false) }
// The calendar whose duration-override picker is open, if any. // 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), title = stringResource(R.string.settings_section_event_form),
onBack = onBack, onBack = onBack,
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
SettingsHint(stringResource(R.string.settings_form_fields_hint)) SettingsHint(stringResource(R.string.settings_form_fields_hint))
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -63,6 +63,7 @@ internal fun NotificationsScreen(
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit, onBack: () -> Unit,
onOpenSpecialDates: () -> Unit, onOpenSpecialDates: () -> Unit,
visible: Boolean = true,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
@@ -92,6 +93,7 @@ internal fun NotificationsScreen(
title = stringResource(R.string.settings_section_notifications), title = stringResource(R.string.settings_section_notifications),
onBack = onBack, onBack = onBack,
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_reminders), title = stringResource(R.string.settings_reminders),
@@ -2,9 +2,7 @@ package de.jeanlucmakiola.calendula.ui.settings
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -66,6 +64,7 @@ import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.openIssueTracker import de.jeanlucmakiola.floret.crash.openIssueTracker
import de.jeanlucmakiola.floret.crash.submitCrashReport import de.jeanlucmakiola.floret.crash.submitCrashReport
import de.jeanlucmakiola.floret.identity.predictiveBackExit
import de.jeanlucmakiola.floret.locale.AppLanguage import de.jeanlucmakiola.floret.locale.AppLanguage
/** The settings sub-screens reached from the hub's category rows. */ /** The settings sub-screens reached from the hub's category rows. */
@@ -85,11 +84,14 @@ fun SettingsScreen(
onManageCalendars: () -> Unit, onManageCalendars: () -> Unit,
onOpenBackup: () -> Unit, onOpenBackup: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
visible: Boolean = true,
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SettingsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) } var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
val slideSpec = rememberCalendarSlideSpec() val slideSpec = rememberCalendarSlideSpec()
// Leaves from the predictive-back preview rather than sliding off at full size.
val overlayExit = predictiveBackExit()
Box( Box(
modifier = modifier modifier = modifier
@@ -101,61 +103,63 @@ fun SettingsScreen(
onOpenSection = { section = it }, onOpenSection = { section = it },
onManageCalendars = onManageCalendars, onManageCalendars = onManageCalendars,
onOpenBackup = onOpenBackup, onOpenBackup = onOpenBackup,
visible = visible,
) )
AnimatedVisibility( AnimatedVisibility(
visible = section == SettingsSection.Appearance, visible = section == SettingsSection.Appearance,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), 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( AnimatedVisibility(
visible = section == SettingsSection.Views, visible = section == SettingsSection.Views,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), 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( AnimatedVisibility(
visible = section == SettingsSection.EventForm, visible = section == SettingsSection.EventForm,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), 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( AnimatedVisibility(
visible = section == SettingsSection.Notifications, visible = section == SettingsSection.Notifications,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
NotificationsScreen( NotificationsScreen(
state = state, state = state,
viewModel = viewModel, viewModel = viewModel,
onBack = { section = null }, onBack = { section = null },
onOpenSpecialDates = { section = SettingsSection.SpecialDates }, onOpenSpecialDates = { section = SettingsSection.SpecialDates },
visible = section == SettingsSection.Notifications,
) )
} }
AnimatedVisibility( AnimatedVisibility(
visible = section == SettingsSection.SpecialDates, visible = section == SettingsSection.SpecialDates,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), 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( AnimatedVisibility(
visible = section == SettingsSection.Widgets, visible = section == SettingsSection.Widgets,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), 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( AnimatedVisibility(
visible = section == SettingsSection.Licences, visible = section == SettingsSection.Licences,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
LicencesScreen(onBack = { section = null }) LicencesScreen(onBack = { section = null }, visible = section == SettingsSection.Licences)
} }
} }
} }
@@ -170,8 +174,14 @@ private fun SettingsHub(
onOpenSection: (SettingsSection) -> Unit, onOpenSection: (SettingsSection) -> Unit,
onManageCalendars: () -> Unit, onManageCalendars: () -> Unit,
onOpenBackup: () -> 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 // Card and support row are one grouped block, so the call to action
// continues the container instead of sitting inside it as a button. // continues the container instead of sitting inside it as a button.
Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() } Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() }
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig 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.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.widget.WidgetSize import de.jeanlucmakiola.calendula.widget.WidgetSize
@@ -34,6 +35,8 @@ data class SettingsUiState(
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO, val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
/** Whether the week/day timeline seats each hour in its own cell (v2.11). */ /** Whether the week/day timeline seats each hour in its own cell (v2.11). */
val showHourGrid: Boolean = false, 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. */ /** How the Agenda screen treats events that already ended today. */
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW, val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
/** Whether the month/week grids fade events that have already finished. */ /** Whether the month/week grids fade events that have already finished. */
@@ -62,6 +65,7 @@ data class SettingsUiState(
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged, val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
/** How tall an hour is drawn in the week and day timelines (#56). */ /** How tall an hour is drawn in the week and day timelines (#56). */
val timelineScale: TimelineScale = TimelineScale.Regular, val timelineScale: TimelineScale = TimelineScale.Regular,
val timelineHours: TimelineHours = TimelineHours(),
/** Order of the views in the navigation drawer (#24); every view is always listed. */ /** Order of the views in the navigation drawer (#24); every view is always listed. */
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS, val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
/** Optional event-form fields shown by default (rest behind "more fields"). */ /** 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.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig 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.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
@@ -148,11 +149,12 @@ class SettingsViewModel @Inject constructor(
combine( combine(
prefs.todayButtonInToolbar, prefs.todayButtonInToolbar,
prefs.dragToReschedule, prefs.dragToReschedule,
::Pair, prefs.multiDayCount,
::Triple,
), ),
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove) -> ) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove, multiDay) ->
DisplayToggles( DisplayToggles(
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove, hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove, multiDay,
) )
}, },
) { view, screenRange, widgetRange, timeFormat, toggles -> ) { view, screenRange, widgetRange, timeFormat, toggles ->
@@ -164,6 +166,7 @@ class SettingsViewModel @Inject constructor(
softenColors = toggles.softenColors, softenColors = toggles.softenColors,
todayButtonInToolbar = toggles.todayButtonInToolbar, todayButtonInToolbar = toggles.todayButtonInToolbar,
dragToReschedule = toggles.dragToReschedule, dragToReschedule = toggles.dragToReschedule,
multiDayCount = toggles.multiDayCount,
) )
}, },
combine( combine(
@@ -178,9 +181,11 @@ class SettingsViewModel @Inject constructor(
prefs.drawerViewOrder, prefs.drawerViewOrder,
prefs.monthViewStyle, prefs.monthViewStyle,
prefs.widgetSize, prefs.widgetSize,
prefs.timelineScale, combine(prefs.timelineScale, prefs.timelineHours, ::Pair),
) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale -> ) { quickSwitch, drawer, monthStyle, widgetSize, (timelineScale, timelineHours) ->
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale) ViewCustomization(
quickSwitch, drawer, monthStyle, widgetSize, timelineScale, timelineHours,
)
}, },
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization -> ) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization) MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
@@ -197,6 +202,7 @@ class SettingsViewModel @Inject constructor(
softenColors = views.softenColors, softenColors = views.softenColors,
todayButtonInToolbar = views.todayButtonInToolbar, todayButtonInToolbar = views.todayButtonInToolbar,
dragToReschedule = views.dragToReschedule, dragToReschedule = views.dragToReschedule,
multiDayCount = views.multiDayCount,
agendaShowRangeBar = misc.showRangeBar, agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle, autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay, pastEventDisplay = misc.pastEventDisplay,
@@ -206,6 +212,7 @@ class SettingsViewModel @Inject constructor(
monthViewStyle = misc.viewCustomization.monthViewStyle, monthViewStyle = misc.viewCustomization.monthViewStyle,
widgetSize = misc.viewCustomization.widgetSize, widgetSize = misc.viewCustomization.widgetSize,
timelineScale = misc.viewCustomization.timelineScale, timelineScale = misc.viewCustomization.timelineScale,
timelineHours = misc.viewCustomization.timelineHours,
allowColorOnUnsupportedCalendars = defaults.allowColor, allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder, defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder, defaultAllDayReminderMinutes = defaults.allDayReminder,
@@ -298,6 +305,7 @@ class SettingsViewModel @Inject constructor(
val softenColors: Boolean, val softenColors: Boolean,
val todayButtonInToolbar: Boolean, val todayButtonInToolbar: Boolean,
val dragToReschedule: Boolean, val dragToReschedule: Boolean,
val multiDayCount: Int,
) )
private data class DisplayToggles( private data class DisplayToggles(
@@ -307,6 +315,7 @@ class SettingsViewModel @Inject constructor(
val softenColors: Boolean, val softenColors: Boolean,
val todayButtonInToolbar: Boolean, val todayButtonInToolbar: Boolean,
val dragToReschedule: Boolean, val dragToReschedule: Boolean,
val multiDayCount: Int,
) )
private data class MiscSettings( private data class MiscSettings(
@@ -323,6 +332,7 @@ class SettingsViewModel @Inject constructor(
val monthViewStyle: MonthViewStyle, val monthViewStyle: MonthViewStyle,
val widgetSize: WidgetSize, val widgetSize: WidgetSize,
val timelineScale: TimelineScale, val timelineScale: TimelineScale,
val timelineHours: TimelineHours,
) )
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */ /** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
@@ -532,6 +542,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setTimeFormat(pref) } viewModelScope.launch { prefs.setTimeFormat(pref) }
} }
fun setMultiDayCount(count: Int) {
viewModelScope.launch { prefs.setMultiDayCount(count) }
}
fun setShowHourGrid(enabled: Boolean) { fun setShowHourGrid(enabled: Boolean) {
viewModelScope.launch { prefs.setShowHourGrid(enabled) } viewModelScope.launch { prefs.setShowHourGrid(enabled) }
} }
@@ -617,6 +631,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setTimelineScale(scale) } viewModelScope.launch { prefs.setTimelineScale(scale) }
} }
fun setTimelineHours(hours: TimelineHours) {
viewModelScope.launch { prefs.setTimelineHours(hours) }
}
fun setDrawerViewOrder(order: List<CalendarView>) { fun setDrawerViewOrder(order: List<CalendarView>) {
viewModelScope.launch { prefs.setDrawerViewOrder(order) } viewModelScope.launch { prefs.setDrawerViewOrder(order) }
} }
@@ -46,6 +46,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
internal fun SpecialDatesScreen( internal fun SpecialDatesScreen(
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit, onBack: () -> Unit,
visible: Boolean = true,
) { ) {
val state by viewModel.specialDatesState.collectAsStateWithLifecycle() val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
val settings by viewModel.state.collectAsStateWithLifecycle() val settings by viewModel.state.collectAsStateWithLifecycle()
@@ -76,6 +77,7 @@ internal fun SpecialDatesScreen(
title = stringResource(R.string.settings_section_special_dates), title = stringResource(R.string.settings_section_special_dates),
onBack = onBack, onBack = onBack,
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
// Paused banner: the permission was revoked after enabling. // Paused banner: the permission was revoked after enabling.
if (state.enabled && state.stalledPermission) { if (state.enabled && state.stalledPermission) {
@@ -21,10 +21,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay 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.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
@@ -32,11 +34,15 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.PickerDescription import de.jeanlucmakiola.calendula.ui.common.PickerDescription
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.TimelineScale
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.descriptionRes 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.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.icon 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.common.labelRes
import de.jeanlucmakiola.calendula.ui.month.labelRes import de.jeanlucmakiola.calendula.ui.month.labelRes
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
@@ -62,6 +68,7 @@ internal fun ViewsScreen(
state: SettingsUiState, state: SettingsUiState,
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit, onBack: () -> Unit,
visible: Boolean = true,
) { ) {
var showMonthStyle by remember { mutableStateOf(false) } var showMonthStyle by remember { mutableStateOf(false) }
var showDefaultView by remember { mutableStateOf(false) } var showDefaultView by remember { mutableStateOf(false) }
@@ -70,11 +77,16 @@ internal fun ViewsScreen(
var showPastEvents by remember { mutableStateOf(false) } var showPastEvents by remember { mutableStateOf(false) }
var showAgendaScreenRange by remember { mutableStateOf(false) } var showAgendaScreenRange by remember { mutableStateOf(false) }
var showTimelineScale 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( CollapsingScaffold(
title = stringResource(R.string.settings_section_views), title = stringResource(R.string.settings_section_views),
onBack = onBack, onBack = onBack,
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
val config = state.quickSwitchConfig val config = state.quickSwitchConfig
@@ -82,7 +94,7 @@ internal fun ViewsScreen(
SectionHeader(stringResource(R.string.settings_views_all_header)) SectionHeader(stringResource(R.string.settings_views_all_header))
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_default_view), title = stringResource(R.string.settings_default_view),
summary = stringResource(state.defaultView.labelRes), summary = state.defaultView.label(),
position = Position.Top, position = Position.Top,
onClick = { showDefaultView = true }, onClick = { showDefaultView = true },
) )
@@ -159,11 +171,56 @@ internal fun ViewsScreen(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
SectionHeader(stringResource(R.string.settings_week_day_header)) SectionHeader(stringResource(R.string.settings_week_day_header))
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_timeline_scale), title = stringResource(R.string.settings_multi_day_count),
summary = stringResource(state.timelineScale.labelRes), summary = multiDayLabel(state.multiDayCount),
position = Position.Top, 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( GroupedRow(
title = stringResource(R.string.settings_hour_lines), title = stringResource(R.string.settings_hour_lines),
summary = stringResource(R.string.settings_hour_lines_summary), summary = stringResource(R.string.settings_hour_lines_summary),
@@ -275,7 +332,7 @@ internal fun ViewsScreen(
predictiveBack = true, predictiveBack = true,
options = IMPLEMENTED_VIEWS, options = IMPLEMENTED_VIEWS,
selected = state.defaultView, selected = state.defaultView,
label = { stringResource(it.labelRes) }, label = { it.label() },
leading = { leading = {
Icon( Icon(
imageVector = it.icon, imageVector = it.icon,
@@ -337,6 +394,18 @@ internal fun ViewsScreen(
onDismiss = { showPastEvents = false }, 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) { if (showTimelineScale) {
OptionPicker( OptionPicker(
title = stringResource(R.string.settings_timeline_scale), title = stringResource(R.string.settings_timeline_scale),
@@ -356,6 +425,30 @@ internal fun ViewsScreen(
onDismiss = { showTimelineScale = false }, 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) { if (showAgendaScreenRange) {
AgendaRangePicker( AgendaRangePicker(
title = stringResource(R.string.settings_agenda_range), title = stringResource(R.string.settings_agenda_range),
@@ -384,7 +477,7 @@ private fun ViewRow(
trailing: @Composable (() -> Unit)? = null, trailing: @Composable (() -> Unit)? = null,
) { ) {
GroupedRow( GroupedRow(
title = stringResource(view.labelRes), title = view.label(),
position = position, position = position,
dimmed = dimmed, dimmed = dimmed,
minHeight = ReorderableRowHeight, minHeight = ReorderableRowHeight,
@@ -449,3 +542,12 @@ internal fun pastEventDisplayLabelRes(mode: PastEventDisplay): Int = when (mode)
PastEventDisplay.DIM -> R.string.settings_past_events_dim PastEventDisplay.DIM -> R.string.settings_past_events_dim
PastEventDisplay.HIDE -> R.string.settings_past_events_hide 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, state: SettingsUiState,
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit, onBack: () -> Unit,
visible: Boolean = true,
) { ) {
var showAgendaWidgetRange by remember { mutableStateOf(false) } var showAgendaWidgetRange by remember { mutableStateOf(false) }
var showWidgetSize by remember { mutableStateOf(false) } var showWidgetSize by remember { mutableStateOf(false) }
@@ -50,6 +51,7 @@ internal fun WidgetsScreen(
title = stringResource(R.string.settings_section_widgets), title = stringResource(R.string.settings_section_widgets),
onBack = onBack, onBack = onBack,
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
SettingsHint(stringResource(R.string.settings_widgets_hint)) SettingsHint(stringResource(R.string.settings_widgets_hint))
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -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)
}
File diff suppressed because it is too large Load Diff
@@ -7,22 +7,29 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.displayPriority
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -30,27 +37,51 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime import kotlinx.datetime.atTime
import kotlinx.datetime.daysUntil
import kotlinx.datetime.minus import kotlinx.datetime.minus
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import java.util.Locale import java.util.Locale
import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.abs
const val MINUTES_PER_DAY: Int = 24 * 60 const val MINUTES_PER_DAY: Int = 24 * 60
/** How far from the requested page [PagedTimelineViewModel.span] keeps other pages cached. */
private const val SPAN_CACHE_DAYS = 28
/** A run of [count] days from [start]: what one timeline page shows. */
data class DaySpan(val start: LocalDate, val count: Int)
/** How the pager cuts the calendar, and the page the anchor sits on. */
data class PagerPosition(val grid: PageGrid, val page: Int)
/** Where [anchor] sits under [rule]. */
fun positionOf(anchor: LocalDate, rule: (LocalDate) -> PageGrid): PagerPosition =
rule(anchor).let { PagerPosition(it, it.pageFor(anchor)) }
/**
* A timeline paged in runs of days: the week view, and the multi-day view (#193).
* The pager leads; the anchor follows it once it settles, and the grid it is
* cut by is derived from the anchor through [gridRule], so a subclass decides
* whether a jump re-frames the pages or only moves between them.
*
* @param gridRule the grid to use for a given anchor, as the settings behind it change.
* @param initialRule the rule used until [gridRule] first emits.
*/
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel abstract class PagedTimelineViewModel(
class WeekViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs, settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher, todaySource: TodaySource,
private val io: CoroutineDispatcher,
gridRule: Flow<(LocalDate) -> PageGrid>,
initialRule: (LocalDate) -> PageGrid,
) : ViewModel() { ) : ViewModel() {
private val zone = TimeZone.currentSystemDefault() private val zone = TimeZone.currentSystemDefault()
private val locale: Locale = Locale.getDefault()
/** Whether to fade events that have already finished (display concern only). */ /** Whether to fade events that have already finished (display concern only). */
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
@@ -61,79 +92,114 @@ class WeekViewModel @Inject constructor(
) )
private val todayDate: LocalDate private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date get() = currentDate()
/** First day of the week, from the Settings preference (AUTO → locale). */ // A day on the visible page; the page itself comes from the grid, so a
private val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart // settings change re-frames the same days instead of jumping.
.map { it.resolveFirstDay(locale) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = DayOfWeek.MONDAY,
)
// Anchor is a representative day inside the visible week; the actual week
// start is derived against [weekStart], so changing the first-day preference
// re-frames the same week instead of jumping.
private val _anchor = MutableStateFlow(todayDate) private val _anchor = MutableStateFlow(todayDate)
val anchor: StateFlow<LocalDate> = _anchor
val weekStartDate: StateFlow<LocalDate> = /**
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) } * The grid for a given anchor, held eagerly so a screen can place its pager
* on its very first frame rather than a frame after the anchor moved.
*/
val rule: StateFlow<(LocalDate) -> PageGrid> =
gridRule.stateIn(viewModelScope, SharingStarted.Eagerly, initialRule)
private var lastToday = todayDate
/** Today, kept current across midnight so a page left open doesn't mark yesterday (#373). */
private val today: StateFlow<LocalDate> = todaySource.today
.onEach { date ->
val old = lastToday
lastToday = date
if (old != date) followToday(old, date)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
private val position: StateFlow<PagerPosition> =
combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) }
.distinctUntilChanged()
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L), started = SharingStarted.WhileSubscribed(5_000L),
initialValue = todayDate.startOfWeek(DayOfWeek.MONDAY), initialValue = positionOf(todayDate, initialRule),
) )
/**
* The anchor page's state. Once loaded it never falls back to [WeekUiState.Loading]:
* moving the anchor keeps the last result until the new page arrives, since
* the pages draw their own days and this only gates the failure screen.
*/
val state: StateFlow<WeekUiState> = val state: StateFlow<WeekUiState> =
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) } position.map { DaySpan(it.grid.startForPage(it.page), it.grid.dayCount) }
.distinctUntilChanged() .distinctUntilChanged()
.flatMapLatest { start -> .flatMapLatest { span -> span(span).filterNot { it is WeekUiState.Loading } }
val range = weekRange(start, zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(start, calendars, instances)
}
}
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L), started = SharingStarted.WhileSubscribed(5_000L),
initialValue = WeekUiState.Loading, initialValue = WeekUiState.Loading,
) )
fun goToPrev() { private val spans = PageStateCache<DaySpan, WeekUiState>(
_anchor.value = _anchor.value.minus(7, DateTimeUnit.DAY) scope = viewModelScope,
initial = WeekUiState.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.
keep = { cached, requested ->
cached.count == requested.count &&
abs(cached.start.daysUntil(requested.start)) <= SPAN_CACHE_DAYS
},
) { span ->
combine(
repository.calendars(),
repository.instances(spanRange(span.start, span.count, zone)),
today,
) { calendars, instances, today ->
buildState(span, calendars, instances, today)
}
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
} }
fun goToNext() { /** The days of one page, shared between that page and [state]. Main thread only. */
_anchor.value = _anchor.value.plus(7, DateTimeUnit.DAY) fun span(span: DaySpan): StateFlow<WeekUiState> = spans.get(span)
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
fun onPageSettled(page: Int) {
val grid = rule.value(_anchor.value)
if (grid.pageFor(_anchor.value) != page) _anchor.value = grid.startForPage(page)
} }
fun goToToday() { /** Move the anchor to [date], re-framing the pages if the grid depends on it. */
_anchor.value = todayDate fun jumpTo(date: LocalDate) {
}
/** Jump to the week containing [date] (drawer jump-to-date). */
fun goToDate(date: LocalDate) {
_anchor.value = date _anchor.value = date
} }
fun goToToday() = jumpTo(todayDate)
/** The date rolled over; a page still showing [old] follows on to [new], one browsed elsewhere stays. */
private fun followToday(old: LocalDate, new: LocalDate) {
val anchor = _anchor.value
val grid = rule.value(anchor)
if (grid.pageFor(anchor) == grid.pageFor(old)) _anchor.value = new
}
fun goToDate(date: LocalDate) = jumpTo(date)
private fun buildState( private fun buildState(
start: LocalDate, span: DaySpan,
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
instances: List<EventInstance>, instances: List<EventInstance>,
today: LocalDate,
): WeekUiState { ): WeekUiState {
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) } calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) } val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
val allDay = instances.filter { it.isAllDay } val allDay = instances.filter { it.isAllDay }
val timed = instances.filterNot { it.isAllDay } val timed = instances.filterNot { it.isAllDay }
return WeekUiState.Success( return WeekUiState.Success(
weekStart = start, weekStart = span.start,
today = todayDate, today = today,
days = days, days = days,
allDaySpans = layoutAllDay(allDay, days, zone), allDaySpans = layoutAllDay(allDay, days, zone),
timedByDay = days.associateWith { day -> layoutDay(timed, day, zone) }, timedByDay = days.associateWith { day -> layoutDay(timed, day, zone) },
@@ -141,6 +207,64 @@ class WeekViewModel @Inject constructor(
} }
} }
/** Whole weeks, starting on the first day from Settings (AUTO → locale). */
@HiltViewModel
class WeekViewModel @Inject constructor(
repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
@IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel(
repository = repository,
settingsPrefs = settingsPrefs,
todaySource = todaySource,
io = io,
gridRule = settingsPrefs.weekStart.map { pref ->
val grid = WeekGrid(pref.resolveFirstDay(Locale.getDefault()))
val rule: (LocalDate) -> PageGrid = { grid }
rule
},
initialRule = { WeekGrid(DayOfWeek.MONDAY) },
)
/**
* The multi-day view (#193): runs of the day count from Settings, with the
* anchor always a page's first day, so today — or a date jumped to — opens in
* the first column.
*/
@HiltViewModel
class MultiDayViewModel @Inject constructor(
repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
@IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel(
repository = repository,
settingsPrefs = settingsPrefs,
todaySource = todaySource,
io = io,
gridRule = settingsPrefs.multiDayCount.map { count ->
{ anchor: LocalDate -> DaysGrid.startingAt(anchor, count) }
},
initialRule = { DaysGrid.startingAt(it, SettingsPrefs.DEFAULT_MULTI_DAY_COUNT) },
)
/** The day view: the timeline one day at a time. */
@HiltViewModel
class DayViewModel @Inject constructor(
repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
@IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel(
repository = repository,
settingsPrefs = settingsPrefs,
todaySource = todaySource,
io = io,
gridRule = flowOf { _: LocalDate -> DayGrid },
initialRule = { DayGrid },
)
/** /**
* Lay out all-day events as connected horizontal spans across the visible week. * Lay out all-day events as connected horizontal spans across the visible week.
* Each event becomes one [AllDaySpan] from its first to its last covered column; * Each event becomes one [AllDaySpan] from its first to its last covered column;
@@ -160,7 +284,13 @@ internal fun layoutAllDay(
} }
// Declined bars are packed after every other one, so on any day they // Declined bars are packed after every other one, so on any day they
// cover they land in a lane below it (#230). // cover they land in a lane below it (#230).
.sortedWith(compareBy({ it.event.isDeclined }, { it.startCol }, { it.endCol })) .sortedWith(
compareBy<Raw> { it.event.displayPriority(zone) }
.thenBy { it.startCol }
.thenBy { it.endCol }
.thenBy { it.event.start }
.thenBy { it.event.instanceId },
)
// What each lane already holds, rather than just how far right it reaches. // What each lane already holds, rather than just how far right it reaches.
// A single "last occupied column" only answers correctly while spans arrive // A single "last occupied column" only answers correctly while spans arrive
@@ -194,10 +324,10 @@ internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
return minus(offset, DateTimeUnit.DAY) return minus(offset, DateTimeUnit.DAY)
} }
/** Half-open instant range covering the seven days starting at [start]. */ /** Instant range covering the [count] days starting at [start]. */
internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange<Instant> { internal fun spanRange(start: LocalDate, count: Int, zone: TimeZone): ClosedRange<Instant> {
val from = start.atStartOfDayIn(zone) val from = start.atStartOfDayIn(zone)
val to = start.plus(6, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone) val to = start.plus(count - 1, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
return from..to return from..to
} }
@@ -32,10 +32,13 @@ internal fun WeekViewPreview(
weekStart: DayOfWeek, weekStart: DayOfWeek,
height: Dp, height: Dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
dayCount: Int = 7,
) { ) {
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date } val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
val state = remember(today, weekStart, zone) { sampleWeekState(today, weekStart, zone) } val state = remember(today, weekStart, dayCount, zone) {
sampleWeekState(today, weekStart, dayCount, zone)
}
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 } snapshotFlow { scrollState.maxValue }.first { it > 0 }
@@ -46,6 +49,8 @@ internal fun WeekViewPreview(
ScaledViewPreview(height = height, modifier = modifier) { ScaledViewPreview(height = height, modifier = modifier) {
WeekSuccess( WeekSuccess(
state = state, state = state,
showWeekNumber = dayCount == 7,
headerBias = if (dayCount == 1) TimelineKind.Day.headerBias else 0f,
scrollState = scrollState, scrollState = scrollState,
allDayHeight = state.allDayStripHeight(), allDayHeight = state.allDayStripHeight(),
dragController = rememberTimelineDragController(), dragController = rememberTimelineDragController(),
@@ -53,6 +58,7 @@ internal fun WeekViewPreview(
onOpenDay = {}, onOpenDay = {},
onCreateAt = { _, _ -> }, onCreateAt = { _, _ -> },
onDrop = {}, onDrop = {},
onResize = {},
) )
} }
} }
@@ -61,10 +67,11 @@ internal fun WeekViewPreview(
private fun sampleWeekState( private fun sampleWeekState(
today: LocalDate, today: LocalDate,
weekStart: DayOfWeek, weekStart: DayOfWeek,
dayCount: Int,
zone: TimeZone, zone: TimeZone,
): WeekUiState.Success { ): WeekUiState.Success {
val start = today.startOfWeek(weekStart) val start = if (dayCount == 7) today.startOfWeek(weekStart) else today
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) } val days = (0 until dayCount).map { start.plus(it, DateTimeUnit.DAY) }
val events = sampleTimelineEvents(days, today, zone) val events = sampleTimelineEvents(days, today, zone)
return WeekUiState.Success( return WeekUiState.Success(
weekStart = start, weekStart = start,

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