diff --git a/.forgejo/ISSUE_TEMPLATE/bug_report.md b/.forgejo/ISSUE_TEMPLATE/bug_report.md index 6a4e08a3..16bac83d 100644 --- a/.forgejo/ISSUE_TEMPLATE/bug_report.md +++ b/.forgejo/ISSUE_TEMPLATE/bug_report.md @@ -21,9 +21,9 @@ labels: - Calendula version: - Android version: - Device: -- Installed from: +- Installed from: - Affected calendar: - Time zone: diff --git a/.forgejo/ISSUE_TEMPLATE/config.yml b/.forgejo/ISSUE_TEMPLATE/config.yml index 2fc3df82..d0159e01 100644 --- a/.forgejo/ISSUE_TEMPLATE/config.yml +++ b/.forgejo/ISSUE_TEMPLATE/config.yml @@ -6,8 +6,8 @@ contact_links: - name: Translate Calendula url: https://weblate.dev.jeanlucmakiola.de/engage/calendula/ about: >- - Translations are managed on Weblate, not here — it owns every values-* - file, so a hand-edited translation gets overwritten on the next sync. + Translations are managed on Weblate. It owns every values-* file, so a + hand-edited translation gets overwritten on the next sync. No coding needed: pick or request a language and translate in the browser. - name: Contributing guide diff --git a/.forgejo/ISSUE_TEMPLATE/crash_report.md b/.forgejo/ISSUE_TEMPLATE/crash_report.md index 4626077d..4cd9deeb 100644 --- a/.forgejo/ISSUE_TEMPLATE/crash_report.md +++ b/.forgejo/ISSUE_TEMPLATE/crash_report.md @@ -1,6 +1,6 @@ --- name: Crash report -about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form. +about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash). It copies the report to your clipboard and prefills this form. title: "Crash: " labels: - bug @@ -11,10 +11,10 @@ labels: ### What happened diff --git a/.forgejo/ISSUE_TEMPLATE/feature_request.md b/.forgejo/ISSUE_TEMPLATE/feature_request.md index 342118d1..ef33df42 100644 --- a/.forgejo/ISSUE_TEMPLATE/feature_request.md +++ b/.forgejo/ISSUE_TEMPLATE/feature_request.md @@ -9,7 +9,7 @@ labels: ### What would you like Calendula to do? -### Why — what problem does it solve? +### What problem does it solve? ### Anything else diff --git a/.forgejo/PULL_REQUEST_TEMPLATE.md b/.forgejo/PULL_REQUEST_TEMPLATE.md index 48026ec1..ab2714ca 100644 --- a/.forgejo/PULL_REQUEST_TEMPLATE.md +++ b/.forgejo/PULL_REQUEST_TEMPLATE.md @@ -5,7 +5,7 @@ Please skim CONTRIBUTING.md if you haven't: https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md Two things it's easy to get wrong: - • Features need a discussed issue first — an undiscussed feature PR may be + • Features need a discussed issue first. An undiscussed feature PR may be closed unmerged even when the code is good. • Target the release branch for your issue's milestone (milestone 2.18.0 → release/v2.18.0), not main. If you targeted main, just say so below and it @@ -17,7 +17,7 @@ Two things it's easy to get wrong: ### Why - + ### How it was tested @@ -34,7 +34,7 @@ are especially useful for UI changes. ### Checklist - [ ] There's an issue for this, and (for a feature) it got a go-ahead -- [ ] Targeting the release branch for that issue's milestone — or `main`, noted above +- [ ] Targeting the release branch for that issue's milestone (or `main`, noted above) - [ ] `./gradlew lint test assembleDebug` passes locally - [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine) - [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index 29ef4490..bbd018ac 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -43,6 +43,19 @@ jobs: - name: Changelog length invariant run: bash scripts/check_changelog_lengths.sh + # The whole listing goes to Play with every release; a field over its cap + # or an image Play rejects would fail that upload. + - name: Store listing invariant + run: | + if ! command -v python3 >/dev/null 2>&1; then + if command -v apt-get >/dev/null 2>&1; then + apt-get update && apt-get install -y python3 + elif command -v apk >/dev/null 2>&1; then + apk add --no-cache python3 + fi + fi + python3 scripts/check_store_listing.py + # Decide whether anything that affects the app build changed. Docs, store # metadata, licence texts and forge housekeeping don't, so those PRs skip # the SDK + Gradle work below but still report a green `ci`. diff --git a/CHANGELOG.md b/CHANGELOG.md index ad9c46b9..c08fa65a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ 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/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [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 ### Added @@ -1774,3 +1800,20 @@ automatically, with zero telemetry and no internet permission. [#304]: https://codeberg.org/jlmakiola/calendula/issues/304 [#321]: https://codeberg.org/jlmakiola/calendula/issues/321 [#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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5948114f..651401b6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,13 +1,13 @@ # Contributing to Calendula Calendula is a Material 3 Expressive calendar app that lives strictly on top of -Android's `CalendarContract` — no app database, no sync stack, no network access. -That constraint shapes most review comments, so -[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) is worth skimming before you write -code. This file is the practical how. +Android's `CalendarContract`, with no app database, sync stack or network access. +That constraint shapes most review comments, so skim +[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) before you write code. This file +covers the practical side. -**[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home** — -issues, pull requests, releases. The self-hosted Gitea instance referenced in the +[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home for +issues, pull requests and releases. The self-hosted Gitea instance referenced in the release docs is build infrastructure only; there is nothing to contribute there. Be decent to the people you meet in the tracker. @@ -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 | | Fix a bug | Open an issue, then a pull request | | Fix a typo, a comment, or docs | Just open the pull request | -| Add or fix a translation | **Don't** — [use Weblate](#translations) | +| Add or fix a translation | Use [Weblate](#translations), not a pull request | Features get an opinion before they get code: whether Calendula should do a thing at all is the one decision a patch can't make. A feature PR that arrives -without a discussed issue may be closed unmerged even when the code is good — +without a discussed issue may be closed unmerged even when the code is good, so please don't spend a weekend on one first. Bugs are more straightforward, but still start with an issue: it's what carries the milestone and gives the changelog something to link. Issue templates cover bug, crash, feature and question. For a crash, let the app -do the work — **Settings → Report a problem**, or the prompt shown after a crash, +do the work: *Settings → Report a problem*, or the prompt shown after a crash, captures the stack trace and prefills the form. The report contains app, Android -and device versions plus the trace; no calendar content, no personal data. +and device versions plus the trace, and no calendar content or personal data. ## 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` | 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. ## 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 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 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 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` does. -## Build & test +## Build and test ```sh 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 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. -- **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 - 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 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 feature rather than an oversight. Anything that would need one is a product - decision before it's a patch — the crash reporter deliberately opens a - prefilled web issue instead of posting anything itself. + decision before it's a patch. The crash reporter opens a prefilled web issue + on purpose instead of posting anything itself. 2. **The provider is the only database.** No Room, no cache, no local mirror of events. `CalendarContract` is the single source of truth, which is also why externally synced changes work for free. 3. **Don't patch UI state after a write.** A `ContentObserver` re-queries and views recompose from fresh provider state. Hand-patching a list after saving - appears to work, then 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 rendering, conflict snapshots and the `.ics` codec stay pure Kotlin so they remain JVM-testable. 5. **Tests run on the JVM.** JUnit 5 + Truth + Turbine. The seams exist for you: fake the data source (`FakeCalendarDataSource`), and feed mappers plain maps through `ColumnReader` instead of cursors. Instrumented tests are a last - resort, not a default. + resort. 6. **Read before touching the subtle pipelines.** Recurring writes (UNTIL vs DURATION, exception URIs, series splits), save-conflict detection and reminder delivery (post-before-mark) follow provider-driven rules that are documented @@ -138,14 +138,14 @@ These are the ones that turn into review comments. ## UI conventions 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. **Selection pickers are full-screen.** Every browse-style "choose one" surface uses floret-kit's `FullScreenPicker` / `OptionPicker`; one that needs a commit or extra action passes it through the picker's `actions` slot. The exception is the recurring-scope chooser (*this / this and following / all*), which stays a -compact dialog — a two- or three-option decision reads better as a popup than as +compact dialog, because a two- or three-option decision reads better as a popup than as a nearly empty screen. `AlertDialog` is for plain confirmations only, and radio- or text-list dialogs aren't used at all. @@ -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 here. -## Commits & pull requests +## Commits and pull requests 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` -`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. +`revert`. Reference the issue in the subject or the body. Keep commits small. +Small commits revert cleanly, which matters more here than a tidy history. If your change is user-visible, add an entry under `## [Unreleased]` in [`CHANGELOG.md`](CHANGELOG.md). Match the surrounding voice: entries describe -what changed *for the person using the app*, and why, not what changed in the -code. Link the issue and add its reference at the bottom of the file. It may get +what changed *for the person using the app*, and why. Code changes don't go +there. Link the issue and add its reference at the bottom of the file. It may get reworded when the release is cut, so don't agonise over it. Please don't commit planning or design documents. Code, tests, architecture notes diff --git a/README.md b/README.md index 88157f8e..a09d8406 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@

Calendula

-

A modern Material 3 Expressive calendar for Android.
-Reads, writes, and reminds — on top of the system calendar, with zero network access.

+

A Material 3 Expressive calendar for Android.
+It works on top of the system calendar and has no network access.

CI @@ -26,84 +26,89 @@ Reads, writes, and reminds — on top of the system calendar, with zero network

-Week view  -Month view  -Day view  -Event detail  -Agenda view  -Reminder onboarding +Week view  +Month view  +Agenda view  +Event detail  +Editing a recurring event  +Calendars from your accounts  +Dark theme

-Calendula is named after the flower whose name — like the word *calendar* — -comes from the Latin *kalendae*, the first day of the month. It lives -entirely on top of Android's `CalendarContract`: any calendar synced to your -device (CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply -appears, and everything you create or edit syncs back the same way. No own -database, no sync stack reinvented. +Calendula is named after the flower. Both its name and the word *calendar* +come from the Latin *kalendae*, the first day of the month. -## ✨ Features +The app works directly on Android's `CalendarContract`. Any calendar synced to +your device (CalDAV via DAVx5, Google, local, WebCal subscriptions) shows up in +Calendula, and whatever you create or edit syncs back the same way. Calendula +has no database or sync code of its own. -**Calendar** +## Features -- Month, week, and day views with a one-tap view switcher -- Full event details — attendees and their responses, reminders, recurrence - (humanized), availability, visibility, foreign time zones -- Per-calendar visibility toggle, grouped by account +### Calendar -**Editing** +- Month, week and day views, with a switcher in the top bar +- Event details with attendees and their responses, reminders, recurrence in + plain words, availability, visibility and foreign time zones +- Calendars can be shown or hidden one by one, grouped by account +- Your contacts' birthdays, anniversaries and other dates, if you turn it on. + Contacts are read on the device and never changed. -- Create, edit, and delete events — including recurring events with scoped - writes: *only this event*, *this and all following*, or *the whole series* -- Recurrence picker with one-tap presets and custom rules (interval, weekday - toggles, end conditions); rules it can't express are preserved verbatim -- Conflict-safe saves: if an event changed elsewhere while you were editing, - Calendula asks instead of silently overwriting -- Read-only calendars (WebCal, birthdays) are detected and respected +### Editing -**Reminders** +- Create, edit and delete events. For a recurring event you choose whether a + change applies to *only this event*, *this and all following*, or *the whole + series*. +- The repeat picker has presets and custom rules (interval, weekdays, end + condition). Rules it can't show are kept as they are. +- If an event changed elsewhere while you were editing it, Calendula asks + before saving over it. +- Read-only calendars such as WebCal subscriptions and birthdays are detected + and can't be edited by accident. -- Event reminders delivered by Calendula itself as notifications — - essential when it's your only calendar app, since Android delegates - reminder delivery to calendar apps -- Tap a reminder to land on the event +### Reminders -**Design & privacy** +- Calendula shows event reminders as notifications itself. Android leaves + reminder delivery to calendar apps, so if Calendula is your only one, this is + where your reminders come from. +- Tapping a reminder opens the event. -- Real Material 3 Expressive throughout — dynamic color (Android 12+), - expressive motion and shapes, light/dark theme -- 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 +### Design and privacy -## 📦 Install +- Material 3 Expressive throughout, with dynamic color on Android 12+, light + and dark themes, and expressive motion and shapes +- Available in 16 languages, with a per-app language setting. + [Help translate](#translations). +- No telemetry, no analytics and no internet permission. Your data stays on + the device. -Pick whichever channel you already use — they all install the same app: +## Install + +All channels install the same app: | Channel | Updates | Notes | | --- | --- | --- | | [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup | -| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; needs the repo added once | -| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automated by Obtainium | -| [Google Play](#google-play) | Per release | Signed with Google's key — see below | +| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; add the repo once | +| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automatic with Obtainium | +| [Google Play](#google-play) | Per release | Signed with Google's key, see below | | [Build from source](#build-from-source) | Whenever you build | Full control | ### F-Droid (recommended) -Calendula is on the **official [F-Droid](https://f-droid.org) repository** — -just search for **Calendula** in any F-Droid client, or +Calendula is in the official [F-Droid](https://f-droid.org) repository. Search +for Calendula in any F-Droid client, or [install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/). -F-Droid rebuilds from source on its own schedule, so a new version usually -shows up there a few days after release. +F-Droid builds from source on its own schedule, so a new version usually shows +up there a few days after release. ### Self-hosted F-Droid repo (fastest updates) -Every release is built, signed, and published to a self-hosted F-Droid -repository as part of the release pipeline, so it lands there first. Add it once +The release pipeline builds, signs and publishes every release to a +self-hosted F-Droid repository, so new versions land there first. Add it once and your F-Droid client handles updates from then on: 1. In your F-Droid client, open *Settings → Repositories → Add* (or open the @@ -117,88 +122,86 @@ and your F-Droid client handles updates from then on: fingerprint (SHA-256): `C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425` -2. Refresh, search for **Calendula**, install. +2. Refresh, search for Calendula and install it. ### Codeberg release / Obtainium -If you'd rather not use F-Droid at all, every release is also published on -**[Codeberg](https://codeberg.org/jlmakiola/calendula/releases)** with the -signed APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum attached — download -and install it directly. +Every release is also on +[Codeberg](https://codeberg.org/jlmakiola/calendula/releases) with the signed +APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum. Download it and install +it directly. -For automatic updates from that channel, use -**[Obtainium](https://github.com/ImranR98/Obtainium)** — on the phone, -**[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula)**, -or do it by hand: *Add App* → paste `https://codeberg.org/jlmakiola/calendula` -→ *Add*. Either way, Obtainium tracks the releases and prompts you when a new -one appears. +For automatic updates from Codeberg, use +[Obtainium](https://github.com/ImranR98/Obtainium). On your phone, +[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula), +or add it by hand: *Add App*, paste `https://codeberg.org/jlmakiola/calendula`, +then *Add*. Obtainium then watches the releases and tells you when a new one +is out. ### Google Play -Calendula is live on Google Play: -**[play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula)**. +Calendula is on +[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 and any other channel requires an uninstall (and with it, a fresh start for -app settings — your events live in the system calendar and are unaffected). +Play and any other channel means uninstalling first. You lose the app settings. +Your events live in the system calendar and aren't affected. ### Build from source -The build is a plain Gradle build with no proprietary dependencies — see -**[docs/BUILDING.md](docs/BUILDING.md)** (note the `floret-kit` submodule). +It's a plain Gradle build with no proprietary dependencies. See +[docs/BUILDING.md](docs/BUILDING.md), and note the `floret-kit` submodule. -Official F-Droid, the self-hosted repo, and the Codeberg releases all share -the same signing key, so you can switch freely between them without -reinstalling. Google Play is the exception — see above. +Official F-Droid, the self-hosted repo and the Codeberg releases share the +same signing key, so you can switch between them without reinstalling. Google +Play is the exception (see above). -## 📚 Documentation +## Documentation -- **[Contributing](CONTRIBUTING.md)** — how to report, propose, and patch -- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks -- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines -- **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next -- **[Privacy policy](docs/PRIVACY.md)** — the source of the page published at +- [Contributing](CONTRIBUTING.md): how to report bugs, propose features and send patches +- [Building from source](docs/BUILDING.md): requirements and Gradle tasks +- [Architecture](docs/ARCHITECTURE.md): the layers and the main pipelines +- [Milestones](https://codeberg.org/jlmakiola/calendula/milestones): what has shipped and what's next +- [Privacy policy](docs/PRIVACY.md): the source of the page at [jeanlucmakiola.de/calendula/privacy](https://jeanlucmakiola.de/calendula/privacy) -## 🤝 Contributing +## Contributing -Bug reports, ideas, and patches are all welcome on -**[Codeberg](https://codeberg.org/jlmakiola/calendula/issues)**. +Bug reports, ideas and patches are welcome on +[Codeberg](https://codeberg.org/jlmakiola/calendula/issues). -The short version: **start with an issue.** Features get a yes-or-no before they -get code, and both features and bugs are assigned a milestone whose -`release/vX.Y.Z` branch your pull request then targets. Typo and docs fixes can -skip straight to a pull request. Translations don't go through pull requests at -all — [Weblate owns them](#-translations). +Start with an issue. A feature gets a yes or no before anyone writes code, and +features and bugs are assigned a milestone whose `release/vX.Y.Z` branch your +pull request targets. Typo and docs fixes can go straight to a pull request. +Translations go through [Weblate](#translations), not pull requests. -Read **[CONTRIBUTING.md](CONTRIBUTING.md)** before writing code: it covers the -workflow, the build (note the `floret-kit` submodule), and the architectural -rules a change is reviewed against. +Read [CONTRIBUTING.md](CONTRIBUTING.md) before writing code. It covers the +workflow, the build (including the `floret-kit` submodule) and the +architectural rules changes are reviewed against. -## 🌍 Translations +## Translations -Calendula ships in English and German, with community translations in Arabic, -Chinese, French, Italian, Polish, Portuguese, Russian, and Spanish at varying -degrees of completeness — partial is fine, untranslated strings simply fall back -to English. You're warmly invited to add or finish your language. Translations -are managed on a self-hosted **Weblate**: +Calendula is written in English. Translators on Weblate have added Arabic, +Belarusian, Chinese (Simplified), Czech, French, German, Hungarian, Italian, +Japanese, Polish, Portuguese (Portugal and Brazil), Russian, Slovak and Spanish, +some of them partly. Untranslated strings fall back to English, so a partial +translation is fine. Translations are managed on a self-hosted Weblate: -**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)** +[Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/) -No coding needed — register on the Weblate server, pick (or request) a language, -and translate the strings in your browser. You can also reach this link in the -app from the top of **Settings → App language**. +You don't need to code. Register on the Weblate server, pick a language (or +request one) and translate in your browser. The app links here from the top of +*Settings → App language*. -## 🔒 Privacy +## Privacy -No analytics, no tracking, no advertising, no crash-reporting SDK, no third-party -services — and **no internet permission at all**, so the app is technically -incapable of transmitting anything anywhere. Your events stay in Android's own -calendar store, where they already were. +Calendula has no analytics, tracking, ads, crash-reporting SDK or third-party +services. It doesn't have the internet permission, so it can't send anything +anywhere. Your events stay in Android's own calendar store. -**→ [Privacy policy](https://jeanlucmakiola.de/calendula/privacy)** — written in +[Privacy policy](https://jeanlucmakiola.de/calendula/privacy), written in [`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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 15ac126a..17ed8628 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // which builds this version and then creates the matching vX.Y.Z tag + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. - versionCode = 22100 - versionName = "2.21.0" + versionCode = 22200 + versionName = "2.22.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index 5de78dba..347b9101 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -32,6 +32,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.buildInsertEventForm import de.jeanlucmakiola.calendula.ui.RootScreen import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid +import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors @@ -169,6 +170,7 @@ class MainActivity : AppCompatActivity() { // weeks and switching between the week and day view. val timelineZoom = rememberTimelineZoom( stored = settings.timelineScale, + hours = settings.timelineHours, onPersist = settingsViewModel::setTimelineScale, ) val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle() @@ -188,6 +190,7 @@ class MainActivity : AppCompatActivity() { LocalUse24HourFormat provides use24Hour, LocalShowHourGrid provides settings.showHourGrid, LocalTimelineZoom provides timelineZoom, + LocalMultiDayCount provides settings.multiDayCount, LocalSoftenColors provides settings.softenColors, ) { RootScreen( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index fd3283dc..6f873afe 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -1261,6 +1261,13 @@ class AndroidCalendarDataSource @Inject constructor( values.toContentValues(), ) ?: throw WriteFailedException("modify occurrence event id=$eventId begin=$beginMillis") 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 // business — reconciling against the actual rows handles both ways. reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes)) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index 138da17c..34c36b29 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -247,6 +247,10 @@ internal fun buildEventUpdateValues( * DTSTART + DURATION itself, clearing the inherited RRULE in the process. This * matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made * "only this event" fail on-device (Codeberg #16). + * + * A palette key is left out: the provider crashes on an exception insert that + * carries `EVENT_COLOR_KEY` (Codeberg #338), so the caller sets it with a plain + * update once the row exists. */ internal fun buildOccurrenceExceptionValues( form: EventForm, @@ -264,7 +268,7 @@ internal fun buildOccurrenceExceptionValues( put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue()) put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null }) put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null }) - putAll(eventColorColumns(form.colorKey, form.color)) + if (form.colorKey == null) putAll(eventColorColumns(null, form.color)) } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 71eaad33..bc098c9e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale import de.jeanlucmakiola.calendula.ui.common.storageValue @@ -287,6 +288,36 @@ class SettingsPrefs @Inject constructor( store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() } } + /** How many days the multi-day view shows side by side (#193). */ + val multiDayCount: Flow = 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 = store.data.map { prefs -> + TimelineHours.of( + enabled = prefs[TIMELINE_HOURS_ENABLED_KEY] ?: false, + startHour = prefs[TIMELINE_HOURS_START_KEY] ?: TimelineHours.DEFAULT_START_HOUR, + endHour = prefs[TIMELINE_HOURS_END_KEY] ?: TimelineHours.DEFAULT_END_HOUR, + ) + } + + suspend fun setTimelineHours(hours: TimelineHours) { + val valid = TimelineHours.of(hours.enabled, hours.startHour, hours.endHour) + store.edit { + it[TIMELINE_HOURS_ENABLED_KEY] = valid.enabled + it[TIMELINE_HOURS_START_KEY] = valid.startHour + it[TIMELINE_HOURS_END_KEY] = valid.endHour + } + } + /** * Where the jump-to-today control lives (issue #60). Default OFF — the * historical layout, where it's an extended FAB that fades in above the "+" @@ -948,10 +979,20 @@ class SettingsPrefs @Inject constructor( if (view in config.enabled) view.name else "!${view.name}" } - /** Keep the given order (de-duplicated), then append any views it omits. */ + /** Keep stored order; insert the new multi-day view after Week on upgrades. */ private fun completeViewOrder(seen: List): List { - val ordered = seen.distinct() - return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered } + val ordered = seen.distinct().toMutableList() + IMPLEMENTED_VIEWS.forEach { view -> + if (view !in ordered) { + val index = if (view == CalendarView.MultiDay && CalendarView.Week in ordered) { + ordered.indexOf(CalendarView.Week) + 1 + } else { + ordered.size + } + ordered.add(index, view) + } + } + return ordered } companion object { @@ -1017,8 +1058,14 @@ class SettingsPrefs @Inject constructor( internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540 private const val MINUTES_PER_DAY = 1_440 internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes") + internal val MULTI_DAY_COUNT_KEY = intPreferencesKey("multi_day_count") + internal val TIMELINE_HOURS_ENABLED_KEY = booleanPreferencesKey("timeline_hours_enabled") + internal val TIMELINE_HOURS_START_KEY = intPreferencesKey("timeline_hours_start") + internal val TIMELINE_HOURS_END_KEY = intPreferencesKey("timeline_hours_end") /** Default snooze delay for the notification "Snooze" action. */ const val DEFAULT_SNOOZE_MINUTES = 10 + const val DEFAULT_MULTI_DAY_COUNT = 3 + val MULTI_DAY_COUNTS = 3..6 internal val CALENDAR_REMINDER_OVERRIDE_KEY = stringPreferencesKey("per_calendar_reminder_override") internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY = diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt index 17d72131..7ca24b74 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt @@ -88,6 +88,7 @@ class ReminderNotifier @Inject constructor( is24Hour = is24Hour, today = Instant.now().atZone(zone).toLocalDate(), firstDayOfWeek = firstDayOfWeek, + todayLabel = context.getString(R.string.reminder_day_today), tomorrowLabel = context.getString(R.string.reminder_day_tomorrow), yesterdayLabel = context.getString(R.string.reminder_day_yesterday), ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt index cdc7462e..c169d709 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt @@ -27,12 +27,12 @@ import java.util.Locale * alone would be ambiguous) * - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time, * already unambiguous) - * - all-day, one day: "11 Jun 2026" - * - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026" + * - all-day, one day: "Tomorrow" / "Thu" / "11 Jun 2026" (same relative rules, + * plus [todayLabel] — no time range to lean on) + * - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end) * - * All-day instances already carry an explicit date, so they never gain a - * relative prefix. They store UTC midnights with an exclusive end, so they are - * read in UTC and the end day is the last *covered* day. + * All-day instances store UTC midnights with an exclusive end, so they are read + * in UTC and the end day is the last *covered* day (issue #308). */ fun reminderTimeText( beginMillis: Long, @@ -43,6 +43,7 @@ fun reminderTimeText( is24Hour: Boolean, today: LocalDate, firstDayOfWeek: DayOfWeek, + todayLabel: String, tomorrowLabel: String, yesterdayLabel: String, ): String { @@ -53,11 +54,16 @@ fun reminderTimeText( val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate() .minusDays(1) .coerceAtLeast(firstDay) - return if (lastDay == firstDay) { - dateFormat.format(firstDay) - } else { - dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay) - } + val start = relativeDayPrefix( + day = firstDay, + today = today, + firstDayOfWeek = firstDayOfWeek, + locale = locale, + dateFormat = dateFormat, + tomorrowLabel = tomorrowLabel, + yesterdayLabel = yesterdayLabel, + ) ?: todayLabel + return if (lastDay == firstDay) start else start + RANGE + dateFormat.format(lastDay) } val timeFormat = timeOfDayFormatter(is24Hour, locale) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt new file mode 100644 index 00000000..1240c5f2 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt @@ -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 = + compareBy { it.displayPriority(zone) } + .thenBy { it.start } + .thenBy { it.title } + .thenBy { it.instanceId } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index dc4d5a89..2fafe096 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -1,24 +1,40 @@ package de.jeanlucmakiola.calendula.ui import androidx.activity.compose.BackHandler +import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.core.SeekableTransitionState +import androidx.compose.animation.core.rememberTransition import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import de.jeanlucmakiola.calendula.ui.common.screen +import de.jeanlucmakiola.calendula.ui.common.ViewScreen +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalDensity import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.domain.EventForm @@ -26,18 +42,39 @@ import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen +import de.jeanlucmakiola.calendula.ui.common.BelowTopBarClip +import de.jeanlucmakiola.calendula.ui.common.StayingPut +import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer +import de.jeanlucmakiola.calendula.ui.common.CalendarFab +import de.jeanlucmakiola.calendula.ui.common.CalendarTopBar +import de.jeanlucmakiola.calendula.ui.common.ChromeHost +import de.jeanlucmakiola.calendula.ui.common.ChromeSlot +import de.jeanlucmakiola.calendula.ui.common.LocalChromeSlot +import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.identity.fadeThrough +import de.jeanlucmakiola.floret.identity.predictiveBackExit +import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.EventMoveHost import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars +import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScroll +import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus +import de.jeanlucmakiola.calendula.ui.common.ViewFocus +import de.jeanlucmakiola.calendula.ui.common.ViewFocusSaver +import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel +import de.jeanlucmakiola.calendula.ui.common.TimelineScrollMemory +import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom +import de.jeanlucmakiola.calendula.ui.common.ViewMorphScope +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform import de.jeanlucmakiola.calendula.ui.common.drillToDay +import de.jeanlucmakiola.calendula.ui.common.drillToWeek import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.selectView import de.jeanlucmakiola.calendula.ui.common.viewBaseStack -import de.jeanlucmakiola.calendula.ui.day.DayScreen import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen import de.jeanlucmakiola.calendula.ui.edit.ImportSource @@ -45,16 +82,21 @@ import de.jeanlucmakiola.calendula.ui.imports.ImportScreen import de.jeanlucmakiola.calendula.ui.month.MonthScreen import de.jeanlucmakiola.calendula.ui.search.SearchScreen import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen -import de.jeanlucmakiola.calendula.ui.week.WeekScreen +import de.jeanlucmakiola.calendula.ui.week.TimelineScreen +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime +import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock /** * Holds the top-level view back stack (spec M1) and swaps between the calendar - * screens. Each screen owns its own ViewModel and date anchor; the view-switcher - * pill in their top bars writes back here via [onSelectView]. + * screens. Each screen owns its own ViewModel; they all open on the one focused + * date held here ([ViewFocus]), and the view-switcher pill in their top bars + * writes back here via [onSelectView]. * * The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view. * A lateral switch (pill / drawer) builds a visit history so back retraces it @@ -71,6 +113,7 @@ import kotlin.time.Clock * as [WidgetNavRequest.OpenEvent], which also roots the back stack in the * widget's view.) */ +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun CalendarHost( modifier: Modifier = Modifier, @@ -102,14 +145,25 @@ fun CalendarHost( var viewStack by rememberSaveable(stateSaver = viewStackSaver) { mutableStateOf(listOf(defaultView)) } + var navigationVersion by remember { mutableIntStateOf(0) } val view = viewStack.last() - val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) } + // The date every view opens on and carries along (#184); tapping a day + // anywhere focuses it and opens the day view there. + val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) } - // Tapping a day in the month grid opens the day view anchored to that date. - var pendingDayIso by rememberSaveable { mutableStateOf(null) } + val switchScope = rememberCoroutineScope() + val switchTo: ((List) -> List) -> Unit = { move -> + navigationVersion++ + viewStack = move(viewStack) + } + val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } } val onOpenDay: (LocalDate) -> Unit = { date -> - pendingDayIso = date.toString() - viewStack = viewStack.drillToDay() + focus.navigateTo(date) + switchTo { it.drillToDay() } + } + val onOpenWeek: (LocalDate) -> Unit = { date -> + focus.navigateTo(date) + switchTo { it.drillToWeek() } } // The event-detail screen (S4) is a full-screen destination hoisted here so @@ -259,13 +313,14 @@ fun CalendarHost( // [requestedDetailKey]. Date/event opens root the stack in the widget's own // view so backing out returns there (then home), not to the default. LaunchedEffect(widgetNavRequest) { + if (widgetNavRequest != null) navigationVersion++ when (val req = widgetNavRequest) { is WidgetNavRequest.OpenDate -> { // Drill the day view in over the widget's view: drop any overlay // that would cover it, so the open doesn't land under Settings/form. dismissCoveringOverlays() createDateIso = null - pendingDayIso = req.dateIso + focus.navigateTo(LocalDate.parse(req.dateIso)) // No widget source (an external date tap) roots over the default // home view, so backing out of the day returns home then exits. viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay() @@ -289,7 +344,7 @@ fun CalendarHost( // default home — so backing out returns to the default, then exits. dismissCoveringOverlays() createDateIso = null - pendingDayIso = null + focus.navigateTo(localToday()) viewStack = viewBaseStack(defaultView, req.view ?: defaultView) onWidgetNavConsumed() } @@ -340,6 +395,9 @@ fun CalendarHost( } val slideSpec = rememberCalendarSlideSpec() + // Overlays leave from the predictive-back preview (#184): shrinking on from + // where the gesture let go instead of snapping back and sliding off. + val overlayExit = predictiveBackExit() // Base-level back: pop the view stack while no overlay covers it (each overlay // owns its own BackHandler and takes precedence). Disabled at the home view, @@ -347,76 +405,158 @@ fun CalendarHost( val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null || editKey != null || showSettings || showCalendars || showBackup || importUri != null || importForm != null - BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) { - viewStack = viewStack.dropLast(1) + // The view switch runs on a seekable transition, so a back gesture can scrub + // it (#185): the previous view morphs in under the finger, releasing finishes + // the pop and cancelling springs back. Reduced motion keeps a plain back. + val viewTransition = remember { SeekableTransitionState(view) } + LaunchedEffect(view) { viewTransition.animateTo(view) } + val reduceMotion = rememberReduceMotion() + val viewBackEnabled = !anyOverlayVisible && viewStack.size > 1 + if (reduceMotion) { + BackHandler(enabled = viewBackEnabled) { + navigationVersion++ + viewStack = viewStack.dropLast(1) + } + } else { + PredictiveBackHandler(enabled = viewBackEnabled) { events -> + val previous = viewStack[viewStack.size - 2] + try { + events.collect { viewTransition.seekTo(it.progress, targetState = previous) } + navigationVersion++ + viewStack = viewStack.dropLast(1) + } catch (e: CancellationException) { + withContext(NonCancellable) { viewTransition.animateTo(viewTransition.currentState) } + throw e + } + } } Box(modifier = modifier.fillMaxSize()) { - // Switching between the peer views (month/week/day/agenda) is lateral + // Switching between the month, timeline and agenda screens is lateral // navigation, so it fades through rather than sliding — paging *within* a - // view keeps the directional slide. AnimatedContent keyed on the view type. + // view keeps the directional slide. What both views show morphs across + // (#184): see [ViewMorphKey]. Reduced motion keeps the plain fade. val viewSwitch = fadeThrough() + val morphBounds = rememberViewMorphBoundsTransform() + val morphFade = rememberCalendarFadeSpec() + val morphClip = remember { BelowTopBarClip() } + val stayingPut = remember { StayingPut() } + val chromeHost = remember { ChromeHost() } + val drawerState = rememberDrawerState(DrawerValue.Closed) + val density = LocalDensity.current + val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) + + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() } + val timelineScroll = remember { TimelineScrollMemory() } + // A timeline reached from another screen opens on the visible hours + // (#322); only a switch between timeline views carries the scroll over. + val timelineZoom = LocalTimelineZoom.current + LaunchedEffect(view.screen) { + if (view.screen != ViewScreen.Timeline && timelineZoom.hours.enabled) { + timelineScroll.midnightY = null + } + } CompositionLocalProvider( LocalEventMove provides moveScope, + LocalTimelineScroll provides timelineScroll, + LocalViewFocus provides focus, // The failure state's way out when every calendar is switched off // (#239); nothing else in the calendar surfaces navigates here. - LocalManageCalendars provides { showCalendars = true }, + LocalManageCalendars provides remember { { showCalendars = true } }, ) { - AnimatedContent( - targetState = view, + // One drawer, top bar and FAB for every screen (#184): a switch changes + // what they say, from what the screen on show publishes, rather than + // swapping them for another screen's. + ModalNavigationDrawer( + drawerState = drawerState, + // Open only via the menu button where edge-swipe would fight paging; + // the agenda has no paging to fight. + gesturesEnabled = drawerState.isOpen || view.screen == ViewScreen.Agenda, + drawerContent = { + CalendarDrawer( + currentView = view, + currentDate = chromeHost.chrome?.drawerDate ?: focus.date, + drawerState = drawerState, + viewOrder = drawerViewOrder, + onSelectView = { target -> + // Once the drawer is out of the way, so the switch is seen whole. + val requestVersion = ++navigationVersion + switchScope.launch { + drawerState.close() + if (navigationVersion == requestVersion) onSelectView(target) + } + }, + onJumpToDate = { target -> + chromeHost.actions?.onJumpToDate?.invoke(target) + switchScope.launch { drawerState.close() } + }, + onSettings = { + onOpenSettings() + switchScope.launch { drawerState.close() } + }, + ) + }, + ) { + Scaffold( + topBar = { + CalendarTopBar( + host = chromeHost, + selectedView = view, + quickSwitchViews = quickSwitchViews, + onCycleView = { onSelectView(viewStack.last().next(quickSwitchViews)) }, + onOpenDrawer = { switchScope.launch { drawerState.open() } }, + onOpenSearch = onOpenSearch, + showTodayButton = todayInToolbar, + ) + }, + floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) }, + ) { innerPadding -> + SharedTransitionLayout( + Modifier + .padding(innerPadding) + .fillMaxSize() +.onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y }, + ) { + // Keyed on the screen, not the view: the day, multi-day and week views + // are one timeline that resizes between them itself (#184), so switching + // among them must not replay this transition. + rememberTransition(viewTransition, label = "view-switch").AnimatedContent( transitionSpec = { viewSwitch }, - label = "view-switch", + contentKey = { it.screen }, ) { currentView -> - when (currentView) { - CalendarView.Week -> WeekScreen( - selectedView = currentView, - onSelectView = onSelectView, + val screen = currentView.screen + val sharedScope = this@SharedTransitionLayout + val visibilityScope = this@AnimatedContent + val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut) { + if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut) + } + CompositionLocalProvider( + LocalViewMorph provides viewMorph, + LocalChromeSlot provides ChromeSlot(chromeHost, current = screen == view.screen), + ) { + when (screen) { + ViewScreen.Timeline -> TimelineScreen( + view = currentView, onOpenDay = onOpenDay, onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, ) - CalendarView.Day -> DayScreen( - selectedView = currentView, - onSelectView = onSelectView, + ViewScreen.Month -> MonthScreen( + onOpenDay = onOpenDay, + onOpenWeek = onOpenWeek, onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, - initialDateIso = pendingDayIso, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, ) - CalendarView.Month -> MonthScreen( - selectedView = currentView, - onSelectView = onSelectView, + ViewScreen.Agenda -> AgendaScreen( onOpenDay = onOpenDay, onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, - ) - CalendarView.Agenda -> AgendaScreen( - selectedView = currentView, - onSelectView = onSelectView, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, - onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, ) } + } + } + } + } } } @@ -429,11 +569,12 @@ fun CalendarHost( AnimatedVisibility( visible = showSearch, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { SearchScreen( onBack = { showSearch = false }, onEventClick = onEventClick, + visible = showSearch, ) } @@ -442,7 +583,7 @@ fun CalendarHost( AnimatedVisibility( visible = detailKey != null, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { activeKey?.let { key -> EventDetailScreen( @@ -462,6 +603,7 @@ fun CalendarHost( importForm = form detailKey = null }, + visible = detailKey != null, ) } } @@ -470,7 +612,7 @@ fun CalendarHost( AnimatedVisibility( visible = createDateIso != null, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { (createDateIso ?: heldCreateIso)?.let { iso -> EventEditScreen( @@ -479,6 +621,7 @@ fun CalendarHost( onClose = { createDateIso = null }, onSaved = { createDateIso = null }, onManageCalendars = { showCalendars = true }, + visible = createDateIso != null, ) } } @@ -487,7 +630,7 @@ fun CalendarHost( AnimatedVisibility( visible = editKey != null, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { (editKey ?: heldEditKey)?.let { key -> EventEditScreen( @@ -499,6 +642,7 @@ fun CalendarHost( detailKey = null }, onManageCalendars = { showCalendars = true }, + visible = editKey != null, ) } } @@ -507,12 +651,13 @@ fun CalendarHost( AnimatedVisibility( visible = showSettings, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { SettingsScreen( onBack = { showSettings = false }, onManageCalendars = { showCalendars = true }, onOpenBackup = { showBackup = true }, + visible = showSettings, ) } @@ -564,11 +709,12 @@ fun CalendarHost( AnimatedVisibility( visible = showCalendars, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { CalendarsScreen( onBack = { showCalendars = false }, onOpenBackup = { showBackup = true }, + visible = showCalendars, ) } @@ -576,7 +722,7 @@ fun CalendarHost( AnimatedVisibility( visible = showBackup, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { // Settings can open this screen without the manager underneath, so // its failure states' "Manage calendars" way out has to open the @@ -603,6 +749,7 @@ fun CalendarHost( showBackup = false showCalendars = false }, + visible = showBackup, ) } } @@ -614,3 +761,6 @@ private val viewStackSaver = listSaver, Int>( save = { stack -> stack.map(CalendarView::ordinal) }, restore = { ordinals -> ordinals.map { CalendarView.entries[it] } }, ) + +private fun localToday(): LocalDate = + Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index bb4ef71b..737b0239 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -1,5 +1,18 @@ package de.jeanlucmakiola.calendula.ui.agenda +import de.jeanlucmakiola.calendula.ui.common.PublishChrome +import de.jeanlucmakiola.calendula.ui.common.ScreenActions +import de.jeanlucmakiola.calendula.ui.common.ScreenBody +import de.jeanlucmakiola.calendula.ui.common.ScreenChrome +import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus +import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus +import kotlinx.coroutines.flow.drop +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.rememberUpdatedState +import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey +import de.jeanlucmakiola.calendula.ui.common.morphEvent import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -18,34 +31,22 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Coffee -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DrawerValue import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -58,14 +59,9 @@ import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.floret.identity.animateItemMotion -import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer -import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn -import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS -import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill -import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.floret.locale.currentLocale @@ -81,26 +77,22 @@ import kotlin.time.Instant @OptIn(ExperimentalMaterial3Api::class) @Composable fun AgendaScreen( - selectedView: CalendarView, - onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, - onOpenSettings: () -> Unit, - onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, quickSwitchViews: List = IMPLEMENTED_VIEWS, - drawerViewOrder: List = IMPLEMENTED_VIEWS, - todayInToolbar: Boolean = false, modifier: Modifier = Modifier, viewModel: AgendaViewModel = hiltViewModel(), ) { + // Opens on the focused date and hands back the day scrolled to the top. + val focus = LocalViewFocus.current + EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) } val state by viewModel.state.collectAsStateWithLifecycle() val anchor by viewModel.anchor.collectAsStateWithLifecycle() val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle() val showToday by viewModel.showToday.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() - val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() var showRangePicker by remember { mutableStateOf(false) } @@ -124,112 +116,88 @@ fun AgendaScreen( // 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 = { + val jumpToToday: () -> Unit = { // Off today, moving the anchor is what resets the list (above); on today // the window doesn't change, so the scroll back is the whole action. if (isOnToday) scope.launch { listState.animateScrollToItem(0) } viewModel.goToToday() + (state as? AgendaUiState.Success)?.today?.let { focus?.date = it } } - ModalNavigationDrawer( - drawerState = drawerState, - drawerContent = { - CalendarDrawer( - currentView = selectedView, - currentDate = anchor, - drawerState = drawerState, - viewOrder = drawerViewOrder, - onSelectView = { view -> - onSelectView(view) - scope.launch { drawerState.close() } - }, - onJumpToDate = { target -> - viewModel.goToDate(target) - scope.launch { drawerState.close() } - }, - onSettings = { - onOpenSettings() - scope.launch { drawerState.close() } - }, - ) - }, - ) { - Scaffold( - modifier = modifier, - topBar = { - AgendaTopBar( - selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, - quickSwitchViews = quickSwitchViews, - onOpenDrawer = { scope.launch { drawerState.open() } }, - onOpenSearch = onOpenSearch, - showTodayButton = todayInToolbar, - onToday = jumpToToday, - ) + PublishChrome( + ScreenChrome( + title = stringResource(R.string.view_agenda), + titleDate = null, + drawerDate = anchor, + awayFromToday = !isOnToday || (scrolledAway && successState != null), + todayText = stringResource(R.string.agenda_today_action), + ), + ScreenActions( + onToday = jumpToToday, + onJumpToDate = { target -> + focus?.date = target + viewModel.goToDate(target) }, - floatingActionButton = { - CalendarFabColumn( - todayVisible = (!isOnToday || (scrolledAway && successState != null)) && - !todayInToolbar, - todayText = stringResource(R.string.agenda_today_action), - onToday = jumpToToday, - onCreate = { onCreateEvent(anchor, null) }, - ) - }, - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - ) { - // One bar at the top: the "showing …" header on the left and the - // session range switcher on the right (one settings toggle). - successState?.takeIf { it.showRangeBar }?.let { s -> - // end lines the selector up with whatever ends the top bar: - // the view switcher's background, or — once #150 hides it — - // the search icon's glyph. - val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) { - AppBarSpacing.Inset - } else { - AppBarSpacing.IconTrailingInset - } - 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 }, - ) - } + onCreate = { onCreateEvent(anchor, null) }, + ), + ) + + ScreenBody(modifier = modifier) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + // One bar at the top: the "showing …" header on the left and the + // session range switcher on the right (one settings toggle). + successState?.takeIf { it.showRangeBar }?.let { s -> + // end lines the selector up with whatever ends the top bar: + // the view switcher's background, or — once #150 hides it — + // the search icon's glyph. + val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) { + AppBarSpacing.Inset + } else { + AppBarSpacing.IconTrailingInset } - AgendaContent( - state = state, - pastDisplay = pastDisplay, - showToday = showToday, - listState = listState, - onRetry = viewModel::goToToday, - onEventClick = onEventClick, - onOpenDay = onOpenDay, + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .weight(1f) - .fillMaxWidth(), - ) + .fillMaxWidth() + .padding( + start = RANGE_BAR_TEXT_INSET, + end = selectorEnd, + top = 8.dp, + bottom = 8.dp, + ), + ) { + AgendaRangeBanner( + range = s.range, + start = s.anchor, + end = s.rangeEnd, + modifier = Modifier.weight(1f), + ) + AgendaRangePill( + range = s.range, + isOverride = s.rangeIsOverride, + onClick = { showRangePicker = true }, + ) + } } + AgendaContent( + state = state, + pastDisplay = pastDisplay, + showToday = showToday, + listState = listState, + onRetry = viewModel::goToToday, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onTopDay = if (successState?.anchor == anchor) { + { focus?.date = it } + } else null, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) } } @@ -326,6 +294,7 @@ internal fun AgendaContent( onOpenDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), + onTopDay: ((LocalDate) -> Unit)? = null, ) { when (state) { AgendaUiState.Loading -> Box(modifier) @@ -368,6 +337,7 @@ internal fun AgendaContent( now = now, onEventClick = onEventClick, onOpenDay = onOpenDay, + onTopDay = onTopDay, modifier = modifier, ) } @@ -386,8 +356,20 @@ private fun AgendaList( now: Instant, onEventClick: (EventInstance) -> Unit, onOpenDay: (LocalDate) -> Unit, + onTopDay: ((LocalDate) -> Unit)?, modifier: Modifier = Modifier, ) { + if (onTopDay != null) { + val itemDates by rememberUpdatedState(agendaItemDates(days)) + val currentOnTopDay by rememberUpdatedState(onTopDay) + // The day the list opened on is where the view already is; only a + // scroll moves it on. + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex } + .drop(1) + .collect { index -> itemDates.getOrNull(index)?.let(currentOnTopDay) } + } + } LazyColumn( modifier = modifier, state = listState, @@ -420,7 +402,7 @@ private fun AgendaList( zone = zone, position = positionOf(index, day.events.size), dimmed = dimPast && event.hasEnded(now), - modifier = animateItemMotion(), + modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)), onClick = { onEventClick(event) }, ) } @@ -430,6 +412,13 @@ private fun AgendaList( } } +/** The day of each item [AgendaList] lays out for [days], in its order. */ +internal fun agendaItemDates(days: List): List = + days.flatMap { day -> + // Header, the placeholder or the events, then the gap. + List(2 + day.events.size.coerceAtLeast(1)) { day.date } + } + @Composable private fun AgendaEmpty(modifier: Modifier = Modifier) { Column( @@ -451,58 +440,3 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) { ) } } - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun AgendaTopBar( - selectedView: CalendarView, - onCycleView: () -> Unit, - quickSwitchViews: List, - 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, - ), - ) -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt index 2c5ec3ad..f38a34df 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.ui.agenda import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.eventDisplayComparator import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.spanFirstDay import de.jeanlucmakiola.calendula.domain.spanLastDay @@ -86,11 +87,7 @@ fun groupAgendaDays( return byDay.map { (date, dayEvents) -> AgendaDay( date = date, - events = dayEvents.sortedWith( - compareByDescending { it.isAllDay } - .thenBy { it.start } - .thenBy { it.title }, - ), + events = dayEvents.sortedWith(eventDisplayComparator(zone)), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt index 7a3af498..2da55f13 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt @@ -82,6 +82,7 @@ private val RESTORE_MIME_TYPES = arrayOf( fun BackupScreen( onBack: () -> Unit, onImport: (Uri) -> Unit, + visible: Boolean = true, viewModel: CalendarsViewModel = hiltViewModel(), ) { val state by viewModel.backupState.collectAsStateWithLifecycle() @@ -137,6 +138,7 @@ fun BackupScreen( scrollable = state is BackupUiState.Ready, snackbarHost = { SnackbarHost(snackbarHostState) }, predictiveBack = true, + visible = visible, ) { when (val s = state) { BackupUiState.Loading -> BackupLoading() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 7d00c20a..1c3f66f4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -133,6 +133,7 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE fun CalendarsScreen( onBack: () -> Unit, onOpenBackup: () -> Unit, + visible: Boolean = true, viewModel: CalendarsViewModel = hiltViewModel(), ) { val calendars by viewModel.calendars.collectAsStateWithLifecycle() @@ -177,6 +178,7 @@ fun CalendarsScreen( onConsumeError = viewModel::consumeError, onOpenBackup = onOpenBackup, onBack = onBack, + visible = visible, onAdd = { editorSession++; editorId = NEW_CALENDAR_ID }, onEdit = { calendar -> editorSession++; editorId = calendar.id }, onSetVisible = viewModel::setCalendarVisible, @@ -193,6 +195,7 @@ private fun CalendarsList( onConsumeError: () -> Unit, onOpenBackup: () -> Unit, onBack: () -> Unit, + visible: Boolean, onAdd: () -> Unit, onEdit: (CalendarSource) -> Unit, onSetVisible: (Long, Boolean) -> Unit, @@ -218,6 +221,7 @@ private fun CalendarsList( onBack = onBack, snackbarHost = { SnackbarHost(snackbarHostState) }, predictiveBack = true, + visible = visible, ) { // What the per-calendar / per-account switches below actually do. HintText(stringResource(R.string.calendars_visibility_hint)) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt index 4b1fcf71..923726ad 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.animation.Crossfade import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme @@ -13,15 +14,19 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.ParentDataModifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import de.jeanlucmakiola.floret.identity.rememberReduceMotion +import kotlin.math.roundToInt /** Gap a timed block leaves to its neighbours in the column. */ val BLOCK_OUTER_INSET = 1.dp @@ -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]. * @@ -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 * once the title and the label's own first line are paid for. * - * A week column is narrower than a "09:30–11:00" range, so the label takes a - * second line rather than lose its end — but only out of a line the title - * measured itself as not needing, never one it would have filled. + * A week column is narrower than a "09:30–11:00" range — and a 12-hour + * "6:00 PM–7:00 PM" can want four lines — so the label wraps over as many as it + * 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 fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int { val timeLineHeight = rememberTrimmedLineHeight( MaterialTheme.typography.labelSmall.asEventTime(), ) - return if (spare >= timeLineHeight) { - blockTextLines( - text = label, - // The style it is drawn in, or the budget measures a line the label - // never uses (#219). - style = MaterialTheme.typography.labelSmall.asEventTime(), - textWidth = textWidth, - max = MAX_TIME_LINES, - ) - } else { - 1 + return blockTextLines( + text = label, + // The style it is drawn in, or the budget measures a line the label + // never uses (#219). + style = MaterialTheme.typography.labelSmall.asEventTime(), + textWidth = textWidth, + max = timeLinesWithin(spare, timeLineHeight), + ) +} + +/** 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. * @@ -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 -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 @@ -239,17 +281,49 @@ data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp) */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement { - val spec: FiniteAnimationSpec = if (rememberReduceMotion() || - LocalTimelineZoom.current.isPinching - ) { - snap() - } else { - MaterialTheme.motionScheme.fastSpatialSpec() +fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement { + val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching + val dpSpec: FiniteAnimationSpec = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec() + val shareSpec: FiniteAnimationSpec = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec() + val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x") + val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width") + val animatedY by animateDpAsState(y, dpSpec, label = "block-y") + val animatedHeight by animateDpAsState(height, dpSpec, label = "block-height") + return BlockPlacement(animatedX, animatedWidth, animatedY, animatedHeight) +} + +/** Seats a block across its [placement]'s share of a [LaneColumn]. */ +fun Modifier.laneSlot(placement: BlockPlacement): Modifier = + then(LaneSlot(placement.xFraction, placement.widthFraction)) + +private data class LaneSlot(val x: Float, val width: Float) : ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any = this@LaneSlot +} + +/** + * A day column's blocks, each measured across the share of the column its + * [laneSlot] gives it; anything else fills the column. The shares are applied + * at layout time, so the column can change width every frame without its + * blocks recomposing. + */ +@Composable +fun LaneColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Layout(content = content, modifier = modifier) { measurables, constraints -> + val width = constraints.maxWidth + val loose = constraints.copy(minWidth = 0, minHeight = 0) + val placed = measurables.map { measurable -> + val slot = measurable.parentData as? LaneSlot + if (slot == null) { + measurable.measure(loose) to 0 + } else { + val left = (slot.x * width).roundToInt() + val right = ((slot.x + slot.width) * width).roundToInt() + val laneWidth = (right - left).coerceAtLeast(0) + measurable.measure(loose.copy(minWidth = laneWidth, maxWidth = laneWidth)) to left + } + } + layout(width, constraints.maxHeight) { + placed.forEach { (placeable, x) -> placeable.placeRelative(x, 0) } + } } - val animatedX by animateDpAsState(x, spec, label = "block-x") - val animatedY by animateDpAsState(y, spec, label = "block-y") - val animatedWidth by animateDpAsState(width, spec, label = "block-width") - val animatedHeight by animateDpAsState(height, spec, label = "block-height") - return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt new file mode 100644 index 00000000..173d29d2 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt @@ -0,0 +1,161 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import de.jeanlucmakiola.calendula.R +import kotlinx.datetime.LocalDate + +/** + * What a calendar screen shows in the top bar, the FAB and the drawer, which the + * host draws once for all of them (#184): switching screens changes what they + * say rather than swapping one bar for another. + * + * @param titleDate the date the title's picker opens on, or null for a title + * that is only a label. + * @param drawerDate the date the drawer's picker opens on. + * @param awayFromToday whether "today" is worth offering. + */ +@Immutable +data class ScreenChrome( + val title: String, + val shortTitle: String = title, + val titleDate: LocalDate?, + val drawerDate: LocalDate, + val awayFromToday: Boolean, + val todayText: String, +) + +/** What the chrome does for the screen on show; read when used, so not observed. */ +class ScreenActions( + val onToday: () -> Unit, + val onJumpToDate: (LocalDate) -> Unit, + val onCreate: () -> Unit, +) + +/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */ +@Stable +class ChromeHost { + var chrome by mutableStateOf(null) + private set + + var actions: ScreenActions? = null + private set + + internal fun publish(chrome: ScreenChrome, actions: ScreenActions) { + this.chrome = chrome + this.actions = actions + } +} + +/** The host's chrome, and whether the screen reading it is the one on show rather than one leaving. */ +class ChromeSlot(val host: ChromeHost, val current: Boolean) + +val LocalChromeSlot = staticCompositionLocalOf { null } + +/** Hands [chrome] and [actions] to the host, while this screen is the one on show. */ +@Composable +fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) { + val slot = LocalChromeSlot.current ?: return + if (slot.current) SideEffect { slot.host.publish(chrome, actions) } +} + +/** The top bar every calendar screen shares. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarTopBar( + host: ChromeHost, + selectedView: CalendarView, + quickSwitchViews: List, + onCycleView: () -> Unit, + onOpenDrawer: () -> Unit, + onOpenSearch: () -> Unit, + showTodayButton: Boolean, +) { + val chrome = host.chrome + TopAppBar( + title = { + if (chrome != null) { + CalendarTitleButton( + title = chrome.title, + currentDate = chrome.titleDate, + onJumpToDate = { host.actions?.onJumpToDate?.invoke(it) }, + shortTitle = chrome.shortTitle, + ) + } + }, + navigationIcon = { + IconButton(onClick = onOpenDrawer) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = stringResource(R.string.month_open_menu), + ) + } + }, + actions = { + TodayAction(show = showTodayButton, onToday = { host.actions?.onToday?.invoke() }) + IconButton(onClick = onOpenSearch) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.search_action), + ) + } + ViewSwitcherPill( + current = selectedView, + cycle = quickSwitchViews, + onCycle = onCycleView, + ) + }, + // Deliberately flat: M3 lifts the bar to mark content scrolling under + // it, but here the bar meets the header on the same surface and the + // tint is what makes that seam look like a separate block (#186). + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + scrolledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) +} + +/** The FAB column every calendar screen shares. */ +@Composable +fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) { + val chrome = host.chrome ?: return + CalendarFabColumn( + todayVisible = chrome.awayFromToday && !todayInToolbar, + todayText = chrome.todayText, + onToday = { host.actions?.onToday?.invoke() }, + onCreate = { host.actions?.onCreate?.invoke() }, + ) +} + +/** + * A calendar screen's body under the shared chrome, on the ground the screen's + * own Scaffold used to give it; the host's Scaffold has already made room for + * the bars, so [content] gets no padding of its own. + */ +@Composable +fun ScreenBody(modifier: Modifier = Modifier, content: @Composable (PaddingValues) -> Unit) { + Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + content(PaddingValues()) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt index f48f8415..e2a1d028 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt @@ -91,7 +91,7 @@ fun CalendarDrawer( DrawerSectionHeader(stringResource(R.string.view_section)) viewOrder.forEachIndexed { index, view -> GroupedRow( - title = stringResource(view.labelRes), + title = view.label(), position = positionOf(index, viewOrder.size), selected = view == currentView, minHeight = 56.dp, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPager.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPager.kt new file mode 100644 index 00000000..e6baa6aa --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPager.kt @@ -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, 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, +) { + if (reduceMotion) scrollToPage(page) else animateScrollToPage(page, animationSpec = spec) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSwipe.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSwipe.kt deleted file mode 100644 index dc15d03f..00000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSwipe.kt +++ /dev/null @@ -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() - } - } - }, - ) - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt index 305f0ecb..3f4f8902 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt @@ -1,5 +1,13 @@ package de.jeanlucmakiola.calendula.ui.common +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row @@ -10,12 +18,12 @@ import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -33,7 +41,8 @@ import kotlinx.datetime.LocalDate * drawer uses and reporting the chosen day through [onJumpToDate]. * * [currentDate] seeds the picker with whatever the bar is currently naming (the - * visible day, week start or month anchor). + * visible day, week start or month anchor); null makes the title a plain label, + * as the agenda's is, and the caret folds away. * * [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 @@ -42,7 +51,7 @@ import kotlinx.datetime.LocalDate @Composable fun CalendarTitleButton( title: String, - currentDate: LocalDate, + currentDate: LocalDate?, onJumpToDate: (LocalDate) -> Unit, modifier: Modifier = Modifier, shortTitle: String = title, @@ -54,6 +63,7 @@ fun CalendarTitleButton( .heightIn(min = 48.dp) .clip(MaterialTheme.shapes.large) .clickable( + enabled = currentDate != null, onClickLabel = stringResource(R.string.drawer_jump_to_date), role = Role.Button, ) { showDatePicker = true } @@ -71,21 +81,38 @@ fun CalendarTitleButton( availableWidth = constraints.maxWidth, ) } - Text( - text = shown, - style = style, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + // Cross-fades, so switching views or paging turns the title over + // rather than snapping it (#184). + val fadeSpec = rememberCalendarFadeSpec() + AnimatedContent( + targetState = shown, + transitionSpec = { + (fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false)) + }, + contentAlignment = Alignment.CenterStart, + ) { text -> + Text( + text = text, + style = style, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + AnimatedVisibility( + visible = currentDate != null, + enter = fadeIn(rememberCalendarFadeSpec()) + expandHorizontally(), + exit = fadeOut(rememberCalendarFadeSpec()) + shrinkHorizontally(), + ) { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) } - if (showDatePicker) { + if (showDatePicker && currentDate != null) { CalendarDatePickerDialog( initial = currentDate, onConfirm = { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt index dba95357..9c5c52ba 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt @@ -1,13 +1,7 @@ package de.jeanlucmakiola.calendula.ui.common -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.SizeTransform import androidx.compose.animation.core.FiniteAnimationSpec -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.spring import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -19,12 +13,12 @@ import androidx.compose.ui.unit.IntOffset * the reduce-motion check — now live in floret-kit's identity module * (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`, * `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays - * here is only what's specific to paging the calendar grid: the directional - * month/week/day slide and the specs that feed it. + * here is only what's specific to paging the calendar grid: the specs its + * pagers settle with, and that the view switch reuses. */ /** - * The M3 Expressive spatial spring used for the month/week/day slide: the + * The M3 Expressive spatial spring the calendar's motion is built on: the * *default* spring-physics spec from the active motion scheme, rather than a * fixed easing curve. * @@ -40,6 +34,12 @@ import androidx.compose.ui.unit.IntOffset fun rememberCalendarSlideSpec(): FiniteAnimationSpec = MaterialTheme.motionScheme.defaultSpatialSpec() +/** The same spring as [rememberCalendarSlideSpec], for a pager settling onto its page. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun rememberCalendarPageSpec(): FiniteAnimationSpec = + MaterialTheme.motionScheme.defaultSpatialSpec() + /** * The effects spec from the active motion scheme, for the opacity half of the * transition. Captured in composable scope alongside [rememberCalendarSlideSpec] @@ -55,50 +55,17 @@ fun rememberCalendarFadeSpec(): FiniteAnimationSpec = MaterialTheme.motionScheme.defaultEffectsSpec() /** - * Navigating between adjacent months/weeks/days, as M3's shared-axis X: the - * outgoing page slides and fades one way while the incoming one arrives from the - * other, position on a spring and opacity on an easing curve. - * - * @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump - * (e.g. "today"); a jump reuses the forward direction. - * @param spec spatial animation spec, typically [rememberCalendarSlideSpec]. - * @param fadeSpec effects spec for the opacity half, and for the whole - * transition under reduced motion; typically - * [rememberCalendarFadeSpec]. - * @param reduceMotion when true, drop the movement and cross-fade alone. + * A switch between views (#184): the default spatial spring, a little softer, as + * the whole screen reshapes rather than a page sliding by; softer still when + * [long], for a change the eye has further to follow. */ -fun calendarSlideTransition( - slideDir: Int, - spec: FiniteAnimationSpec, - fadeSpec: FiniteAnimationSpec, - reduceMotion: Boolean, -): ContentTransform { - if (reduceMotion) { - return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) - } - val dir = if (slideDir == 0) 1 else slideDir - return ContentTransform( - targetContentEnter = - slideInHorizontally(spec) { w -> dir * w / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec), - initialContentExit = - slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec), - // AnimatedContent clips to the animating container by default, which - // shears the pages against the viewport edge as they pass. 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), +@Composable +fun rememberViewSwitchSpec(long: Boolean = false): FiniteAnimationSpec = + spring( + dampingRatio = VIEW_SWITCH_DAMPING, + stiffness = if (long) LONG_VIEW_SWITCH_STIFFNESS else VIEW_SWITCH_STIFFNESS, ) -} -/** - * How far a page travels, as a fraction of the container width. - * - * A full width was the obvious reading of "paging", but the two pages are - * stacked and both opaque, so a full-width slide showed one grid racing across - * another — the movement carried the whole transition and had a long way to go. - * Under M3's shared-axis pattern the offset only has to *hint* the direction - * while the cross-fade does the swapping, so a fifth of the width is plenty and - * leaves nothing skating past. - */ -private const val SLIDE_TRAVEL_DIVISOR = 5 +private const val VIEW_SWITCH_DAMPING = 0.9f +private const val VIEW_SWITCH_STIFFNESS = 450f +private const val LONG_VIEW_SWITCH_STIFFNESS = 340f diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt index 18bb606c..7715ee27 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt @@ -1,37 +1,53 @@ package de.jeanlucmakiola.calendula.ui.common -import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CalendarViewDay import androidx.compose.material.icons.filled.CalendarViewMonth import androidx.compose.material.icons.filled.CalendarViewWeek import androidx.compose.material.icons.filled.ViewAgenda +import androidx.compose.material.icons.filled.ViewColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs /** The top-level calendar views the user can switch between (spec M1). */ enum class CalendarView { Month, Week, + MultiDay, Day, Agenda, } -/** Switcher label, shared by the top-bar pill and the drawer's View section. */ -@get:StringRes -val CalendarView.labelRes: Int - get() = when (this) { - CalendarView.Month -> R.string.view_month - CalendarView.Week -> R.string.view_week - CalendarView.Day -> R.string.view_day - CalendarView.Agenda -> R.string.view_agenda +/** How many days the multi-day view shows (#193), provided once at the app root. */ +val LocalMultiDayCount = staticCompositionLocalOf { SettingsPrefs.DEFAULT_MULTI_DAY_COUNT } + +/** + * The view's name, shared by the top-bar pill, the drawer and Settings. The + * multi-day view is named after its day count ("3 days"). + */ +@Composable +fun CalendarView.label(): String = when (this) { + CalendarView.Month -> stringResource(R.string.view_month) + CalendarView.Week -> stringResource(R.string.view_week) + CalendarView.MultiDay -> { + val count = LocalMultiDayCount.current + pluralStringResource(R.plurals.view_multi_day, count, count) } + CalendarView.Day -> stringResource(R.string.view_day) + CalendarView.Agenda -> stringResource(R.string.view_agenda) +} /** Leading icon for the view in the drawer's View section. */ val CalendarView.icon: ImageVector get() = when (this) { CalendarView.Month -> Icons.Filled.CalendarViewMonth CalendarView.Week -> Icons.Filled.CalendarViewWeek + CalendarView.MultiDay -> Icons.Filled.ViewColumn CalendarView.Day -> Icons.Filled.CalendarViewDay CalendarView.Agenda -> Icons.Filled.ViewAgenda } @@ -41,7 +57,13 @@ val CalendarView.icon: ImageVector * through these in order. */ val IMPLEMENTED_VIEWS: List = - listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day, CalendarView.Agenda) + listOf( + CalendarView.Month, + CalendarView.Week, + CalendarView.MultiDay, + CalendarView.Day, + CalendarView.Agenda, + ) /** Next view in [available], wrapping around. Falls back to Month if absent. */ fun CalendarView.next(available: List = IMPLEMENTED_VIEWS): CalendarView { @@ -107,3 +129,16 @@ fun viewBaseStack(default: CalendarView, source: CalendarView): List.drillToDay(): List = 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.drillToWeek(): List = + if (lastOrNull() == CalendarView.Week) this else this + CalendarView.Week diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt index 330fb439..fe933e5b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt @@ -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 * 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) { this } else { @@ -71,10 +75,11 @@ fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Mo hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()), ) val cellSize = Size(size.width, cellHeight) + val top = inset for (hour in 0 until 24) { drawRoundRect( color = color, - topLeft = Offset(0f, hour * hourHeightPx + inset), + topLeft = Offset(0f, hour * hourHeightPx + top), size = cellSize, cornerRadius = radius, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt new file mode 100644 index 00000000..7954e3c4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt @@ -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, + topMin: Float, + bottomMin: Float, +): OutOfViewEvents { + val above = blocks + .filter { renderedEndMin(it) <= topMin } + .maxWithOrNull(compareBy({ renderedEndMin(it) }, { it.startMin })) + val below = blocks + .filter { it.startMin >= bottomMin } + .minWithOrNull(compareBy({ 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()) + 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? = 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, + 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), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PageStateCache.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PageStateCache.kt new file mode 100644 index 00000000..8358820a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PageStateCache.kt @@ -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( + private val scope: CoroutineScope, + private val initial: S, + private val keep: (cached: K, requested: K) -> Boolean, + private val load: (K) -> Flow, +) { + private class Entry(val state: StateFlow, val job: Job) + + private val entries = HashMap>() + + /** The number of entries held, for tests. */ + internal val size: Int get() = entries.size + + fun get(key: K): StateFlow { + 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 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index 5ee2863f..0f2fae46 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -284,8 +284,8 @@ class TimelineGeometry { } /** - * Hoisted drag state for one timeline (#68). It lives above the per-page - * `AnimatedContent`, so a page change mid-drag can't strand a ghost, and the + * Hoisted drag state for one timeline (#68). It lives above the pager, so a + * page change mid-drag can't strand a ghost, and the * block it renders is drawn in an overlay, clear of the day column's clip. */ @Stable @@ -710,7 +710,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = val startMin = drag.eventStartMin.mod(MINUTES_PER_DAY) val rawEnd = startMin + drag.eventSpanMin 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) // 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 @@ -764,7 +764,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = title = title, titleLines = drag.titleLines, // Once for the whole event: repeated, it would name it per day. - label = label.takeIf { index == labelled }, + range = range.takeIf { index == labelled }, ) } } @@ -784,7 +784,7 @@ private fun DragCopy( alpha: Float, title: String, titleLines: Int, - label: String?, + range: Pair?, ) { val density = LocalDensity.current val width = with(density) { sizePx.width.toDp() } @@ -814,6 +814,7 @@ private fun DragCopy( ) } 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 timeMaxLines = if (showTime) { blockTimeLines(label!!, textWidth, left - metrics.timeLine) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineHours.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineHours.kt new file mode 100644 index 00000000..1efb9869 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineHours.kt @@ -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 = (0..endHour - MIN_SPAN).toList() + + /** End hours at least [MIN_SPAN] after [startHour]; 24 is midnight. */ + fun endOptions(startHour: Int): List = (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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt index d7c69f1c..227e5f6a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt @@ -38,6 +38,12 @@ sealed interface TimelineScale { /** A height the user pinched to. Build it through [custom], which clamps. */ data class Custom(val hourHeight: Dp) : TimelineScale + /** + * [startHour]..[endHour] fills the viewport (#322). Applied when a timeline + * opens with [TimelineHours] on, never picked from the preset list. + */ + data class FitHours(val startHour: Int, val endHour: Int) : TimelineScale + companion object { /** The named steps the settings picker offers, coarse to roomy. */ val presets: List = listOf(FitDay, Compact, Regular, Comfortable) @@ -74,6 +80,9 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) { is TimelineScale.Custom -> this.hourHeight .coerceAtMost(MAX_PINCH_HOUR_HEIGHT) .coerceAtLeast(fillHourHeight(viewportHeight)) + is TimelineScale.FitHours -> (viewportHeight / (endHour - startHour).coerceIn(1, 24).toFloat()) + .coerceAtMost(MAX_PINCH_HOUR_HEIGHT) + .coerceAtLeast(fillHourHeight(viewportHeight)) } /** @@ -134,6 +143,7 @@ val TimelineScale.labelRes: Int TimelineScale.Regular -> R.string.timeline_scale_regular TimelineScale.Comfortable -> R.string.timeline_scale_comfortable is TimelineScale.Custom -> R.string.timeline_scale_custom + is TimelineScale.FitHours -> R.string.settings_visible_hours } @get:StringRes @@ -144,11 +154,15 @@ val TimelineScale.descriptionRes: Int TimelineScale.Regular -> R.string.timeline_scale_regular_summary TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary is TimelineScale.Custom -> R.string.timeline_scale_custom_summary + is TimelineScale.FitHours -> R.string.settings_visible_hours_summary } /** Marks a stored custom height; the rest of the value is its dp. */ private const val CUSTOM_PREFIX = "custom:" +/** Marks a stored hour range, as `start-end`. */ +private const val HOURS_PREFIX = "hours:" + /** * Stored names for the presets. Spelled out rather than taken from `toString()` * so R8 can't rename them out from under an existing install — and they match @@ -171,6 +185,7 @@ private val PRESET_NAMES: Map = mapOf( */ fun TimelineScale.storageValue(): String = when (this) { is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value + is TimelineScale.FitHours -> "$HOURS_PREFIX$startHour-$endHour" TimelineScale.FitDay, TimelineScale.Compact, TimelineScale.Regular, @@ -188,5 +203,11 @@ fun parseTimelineScale(stored: String?): TimelineScale = when { stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull() ?.let { TimelineScale.custom(it.dp) } ?: TimelineScale.Regular + stored.startsWith(HOURS_PREFIX) -> stored.removePrefix(HOURS_PREFIX).split('-') + .mapNotNull { it.toIntOrNull() } + .takeIf { it.size == 2 } + ?.let { (start, end) -> TimelineHours.of(enabled = true, start, end) } + ?.let { TimelineScale.FitHours(it.startHour, it.endHour) } + ?: TimelineScale.Regular else -> PRESET_NAMES.entries.firstOrNull { it.value == stored }?.key ?: TimelineScale.Regular } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt new file mode 100644 index 00000000..7282db71 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt @@ -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 { 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 { 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt index 69527f9c..3b6d3e6e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt @@ -49,13 +49,49 @@ class TimelineZoom( var isPinching: Boolean by mutableStateOf(false) private set + /** The visible-hours preference (#322); a timeline re-fits when it changes. */ + var hours: TimelineHours by mutableStateOf(TimelineHours()) + private set + + private var stored: TimelineScale = initial + /** * Take a value that came from the preference. Ignored mid-pinch: the stored * value is a frame or two behind the fingers there, and letting it land would - * snap the timeline back while the user is still pinching. + * snap the timeline back while the user is still pinching. With visible + * hours on it is only remembered, so a late-arriving preference can't undo + * the fit a timeline just opened with. */ fun adopt(stored: TimelineScale) { - if (!isPinching) scale = stored + this.stored = stored + if (!isPinching && !hours.enabled) scale = stored + } + + /** Take the visible-hours preference; switching it off hands back the stored scale. */ + fun adoptHours(hours: TimelineHours) { + val wasEnabled = this.hours.enabled + this.hours = hours + if (wasEnabled && !hours.enabled && !isPinching) scale = stored + } + + /** + * A timeline is opening. With visible hours on, the range replaces whatever + * scale is in force — a stored preset and a pinch alike — and its start hour + * is returned for the timeline to scroll to; otherwise null. + */ + fun open(): Int? { + val hours = hours + if (!hours.enabled) return null + if (!isPinching) scale = TimelineScale.FitHours(hours.startHour, hours.endHour) + return hours.startHour + } + + /** + * Holds the height [open] fitted, so a viewport that changes afterwards (the + * all-day strip following a swipe) doesn't rescale the hours under the scroll. + */ + fun holdFit(hourHeight: Dp) { + if (!isPinching && scale is TimelineScale.FitHours) scale = TimelineScale.Custom(hourHeight) } fun beginPinch() { @@ -66,9 +102,10 @@ class TimelineZoom( scale = TimelineScale.custom(hourHeight) } + /** Settles the pinch. With visible hours on it only lasts until the next [open]. */ fun endPinch() { isPinching = false - persist(scale) + if (!hours.enabled) persist(scale) } } @@ -82,16 +119,18 @@ private val DetachedTimelineZoom by lazy { TimelineZoom(TimelineScale.Regular) { val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom } /** - * The app-wide [TimelineZoom], seeded from [stored] and writing settled pinches - * back through [onPersist]. + * The app-wide [TimelineZoom], seeded from [stored] and [hours] and writing + * settled pinches back through [onPersist]. */ @Composable fun rememberTimelineZoom( stored: TimelineScale, + hours: TimelineHours, onPersist: (TimelineScale) -> Unit, ): TimelineZoom { val persist by rememberUpdatedState(onPersist) val zoom = remember { TimelineZoom(stored) { persist(it) } } + LaunchedEffect(hours) { zoom.adoptHours(hours) } // Picking a preset in Settings has to reach the timelines, and so does the // stored value arriving after the first frame. LaunchedEffect(stored) { zoom.adopt(stored) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt index 583ee4a0..d1b27d48 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt @@ -7,9 +7,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.Text +import androidx.compose.runtime.getValue import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -59,7 +59,8 @@ fun TodayAction(show: Boolean, onToday: () -> Unit) { val description = stringResource(R.string.today_jump_action) IconButton( onClick = onToday, - modifier = Modifier.semantics { contentDescription = description }, + modifier = Modifier + .semantics { contentDescription = description }, ) { val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() } Box( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt new file mode 100644 index 00000000..27bf4a07 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt @@ -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 = Saver( + save = { it.date.toString() }, + restore = { ViewFocus(LocalDate.parse(it)) }, +) + +val LocalViewFocus = staticCompositionLocalOf { 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 EnterOnFocus(key: T, enter: (LocalDate) -> Unit) { + val focus = LocalViewFocus.current + remember(key) { focus?.date?.also(enter) } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt new file mode 100644 index 00000000..9460bfcc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt @@ -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, + 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() +} + +/** 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 { 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() + return remember(spec) { BoundsTransform { _, _ -> spec } } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt index 3c606270..b33d28c0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt @@ -1,12 +1,17 @@ package de.jeanlucmakiola.calendula.ui.common +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.padding import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp /** @@ -30,11 +35,23 @@ fun ViewSwitcherPill( trailingInset: Dp = AppBarSpacing.ContainerTrailingInset, ) { if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return + val fadeSpec = rememberCalendarFadeSpec() FilledTonalButton( onClick = onCycle, 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) + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayPaging.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayPaging.kt new file mode 100644 index 00000000..b932d7df --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayPaging.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt deleted file mode 100644 index 49d66782..00000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ /dev/null @@ -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 = IMPLEMENTED_VIEWS, - drawerViewOrder: List = 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, - 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, - 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() - 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", - ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayUiState.kt deleted file mode 100644 index 2883366a..00000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayUiState.kt +++ /dev/null @@ -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, - /** Timed events clipped to this day with overlap lanes resolved. */ - val timed: List, - ) : DayUiState -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt deleted file mode 100644 index 4146aee9..00000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt +++ /dev/null @@ -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 = _date - - val state: StateFlow = _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, - instances: List, - ): 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 { - val from = date.atStartOfDayIn(zone) - val to = date.atTime(23, 59, 59).toInstant(zone) - return from..to -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt deleted file mode 100644 index 90303d93..00000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt +++ /dev/null @@ -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), - ) -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 2360edd1..db61df95 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -140,6 +140,7 @@ fun EventDetailScreen( onBack: () -> Unit, onEdit: () -> Unit, onDuplicate: (EventForm) -> Unit, + visible: Boolean = true, viewModel: EventDetailViewModel = hiltViewModel(), ) { LaunchedEffect(eventId, beginMillis, endMillis) { @@ -227,7 +228,7 @@ fun EventDetailScreen( } Scaffold( - modifier = Modifier.predictiveBack(onBack = onBack), + modifier = Modifier.predictiveBack(onBack = onBack, visible = visible), snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index f591797b..90dff7a1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -99,6 +99,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle 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.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization @@ -203,6 +204,7 @@ fun EventEditScreen( initialForm: EventForm? = null, initialFormSource: ImportSource = ImportSource.File, onManageCalendars: (() -> Unit)? = null, + visible: Boolean = true, viewModel: EventEditViewModel = hiltViewModel(), ) { LaunchedEffect(initialDateIso, editKey, initialForm) { @@ -285,7 +287,7 @@ fun EventEditScreen( } Scaffold( - modifier = Modifier.predictiveBack(onBack = close), + modifier = Modifier.predictiveBack(onBack = close, visible = visible), snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( @@ -608,6 +610,11 @@ private fun EventEditContent( .padding(vertical = 4.dp) .focusRequester(titleFocusRequester), ) + FieldLengthCounter( + length = form.title.length, + max = MAX_SINGLE_LINE_FIELD, + modifier = Modifier.fillMaxWidth(), + ) Spacer(Modifier.height(10.dp)) Box( modifier = Modifier @@ -797,25 +804,31 @@ private fun EventEditContent( iconAtTop = true, ) { Row(verticalAlignment = Alignment.Top) { - InlineField( - value = form.location, - onValueChange = viewModel::setLocation, - placeholder = stringResource(R.string.event_detail_location), - // A location is as often a meeting URL as an address, - // and "Zoom.us/j/123" reads wrong (#146). - capitalization = KeyboardCapitalization.None, - // Multi-line so a long address or meeting URL wraps and - // the card grows instead of scrolling off one line - // (#273). The field holds no newlines of its own, so - // the IME's action key closes the keyboard rather than - // inserting a break setLocation would only fold away. - singleLine = false, - imeAction = ImeAction.Done, - modifier = Modifier - .weight(1f) - .animateContentSizeMotion() - .padding(vertical = 4.dp), - ) + Column(modifier = Modifier.weight(1f)) { + InlineField( + value = form.location, + onValueChange = viewModel::setLocation, + placeholder = stringResource(R.string.event_detail_location), + // A location is as often a meeting URL as an address, + // and "Zoom.us/j/123" reads wrong (#146). + capitalization = KeyboardCapitalization.None, + // Multi-line so a long address or meeting URL wraps and + // the card grows instead of scrolling off one line + // (#273). The field holds no newlines of its own, so + // the IME's action key closes the keyboard rather than + // inserting a break setLocation would only fold away. + singleLine = false, + imeAction = ImeAction.Done, + modifier = Modifier + .fillMaxWidth() + .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 // centres on the first line without making a single-line // 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 * field's 4dp vertical padding. In dp, so it tracks the user's font scale diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 017383d4..99894e80 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -62,6 +62,26 @@ private const val TAG = "EventEdit" /** Any line break, with the whitespace around it, as one match. */ 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 * 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 // 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. - fun setTitle(value: String) = - update { it.copy(title = value.replace("\n", "").replace("\r", "")) } + fun setTitle(value: String) = update { + 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 // multi-line address joins the way the contact picker's does (#146), so it // never runs together into "12 Main St10115 Berlin". - fun setLocation(value: String) = - update { it.copy(location = value.replace(LINE_BREAK, ", ")) } + fun setLocation(value: String) = update { + val cleaned = value.replace(LINE_BREAK, ", ") + it.copy(location = capLength(cleaned, it.location)) + } fun setDescription(value: String) = update { it.copy(description = value) } fun setAllDay(value: Boolean) { // Going all-day drops any pinned zone: the times become bare dates that diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt index fcf33d3f..82054656 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt @@ -78,6 +78,7 @@ fun ImportScreen( onOpenSingle: (EventForm) -> Unit, forceMany: Boolean = false, onManageCalendars: (() -> Unit)? = null, + visible: Boolean = true, viewModel: ImportViewModel = hiltViewModel(), ) { // hiltViewModel() resolves to the Activity's store and is retained across @@ -103,7 +104,7 @@ fun ImportScreen( Scaffold( modifier = Modifier - .predictiveBack(onBack = onClose) + .predictiveBack(onBack = onClose, visible = visible) .fillMaxSize(), topBar = { TopAppBar( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/licences/LicencesScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/licences/LicencesScreen.kt index da70e2d9..8e4674fe 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/licences/LicencesScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/licences/LicencesScreen.kt @@ -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. */ @Composable -internal fun LicencesScreen(onBack: () -> Unit) { +internal fun LicencesScreen(onBack: () -> Unit, visible: Boolean = true) { val uriHandler = LocalUriHandler.current CollapsingScaffold( title = stringResource(R.string.licences_title), onBack = onBack, predictiveBack = true, + visible = visible, ) { Text( stringResource(R.string.licences_intro), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 9ea89051..8dd73bb4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -2,26 +2,50 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.EnterExitState +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerDefaults +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberUpdatedState +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.LocalViewMorph +import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus +import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus +import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey +import de.jeanlucmakiola.calendula.ui.common.morphDay +import de.jeanlucmakiola.calendula.ui.common.morphEvent +import de.jeanlucmakiola.calendula.ui.common.morphPageActive +import de.jeanlucmakiola.calendula.ui.common.jumpToPage +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.drop import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween +import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -43,39 +67,37 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DrawerValue -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.key import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -92,6 +114,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventPaint import de.jeanlucmakiola.calendula.ui.common.eventSurface import de.jeanlucmakiola.calendula.ui.common.monthBarCuts import kotlin.math.roundToInt +import kotlin.math.abs import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface import de.jeanlucmakiola.calendula.ui.common.eventMoveAction import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow @@ -106,7 +129,6 @@ import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS import de.jeanlucmakiola.calendula.ui.common.EventMoveScope -import de.jeanlucmakiola.calendula.domain.spanFirstDay import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.semantics.customActions @@ -116,10 +138,13 @@ import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -137,14 +162,10 @@ import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow +import de.jeanlucmakiola.calendula.ui.agenda.agendaDayLabel import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle -import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing -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.CALENDAR_SWIPE_THRESHOLD import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha @@ -157,15 +178,12 @@ import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.trimmedLines import de.jeanlucmakiola.calendula.ui.common.monthBarShape import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute -import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill -import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.itemEnter import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale -import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.time.isoWeekNumber import kotlinx.coroutines.delay @@ -180,28 +198,74 @@ import kotlinx.datetime.plus import kotlinx.datetime.YearMonth import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toLocalDateTime -import kotlin.math.abs import kotlin.time.Clock import kotlin.time.Instant import java.time.format.TextStyle as JavaTextStyle import java.util.Locale +private val LocalTodayPulse = staticCompositionLocalOf { 0 } +private val LocalOpenWeek = staticCompositionLocalOf<((LocalDate) -> Unit)?> { null } + +/** A brief push from today's full cell into its immediate neighbours. */ +@Composable +private fun rememberTodayRipple(active: Boolean): Animatable { + val pulse = remember { Animatable(0f) } + val pulseKey = LocalTodayPulse.current + val reduceMotion = rememberReduceMotion() + LaunchedEffect(pulseKey, active, reduceMotion) { + if (active && pulseKey > 0 && !reduceMotion) { + pulse.snapTo(0f) + pulse.animateTo(1f, tween(durationMillis = 180)) + pulse.animateTo( + 0f, + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow, + ), + ) + } else { + pulse.snapTo(0f) + } + } + return pulse +} + +/** Today's cell: a light primary tint over the ordinary cell, set apart without shouting (#241). */ +@Composable +private fun todayCellColor(): Color = + MaterialTheme.colorScheme.primaryContainer.copy(alpha = TODAY_CELL_TINT) + .compositeOver(MaterialTheme.colorScheme.surfaceContainer) + +private const val TODAY_CELL_TINT = 0.45f + +/** Paint-only motion keeps hit targets and the month grid's lane geometry stable. */ +private fun Modifier.todayRipple(relativeColumn: Int?, progress: () -> Float): Modifier { + if (relativeColumn == null || abs(relativeColumn) > 1) return this + return graphicsLayer { + val pulse = progress() + if (relativeColumn == 0) { + scaleX = 1f + 0.10f * pulse + scaleY = 1f + 0.06f * pulse + } else { + translationX = (if (relativeColumn < 0) -1f else 1f) * 4.dp.toPx() * pulse + scaleX = 1f - 0.025f * pulse + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun MonthScreen( - selectedView: CalendarView, - onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, + onOpenWeek: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, - onOpenSettings: () -> Unit, - onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, - quickSwitchViews: List = IMPLEMENTED_VIEWS, - drawerViewOrder: List = IMPLEMENTED_VIEWS, - todayInToolbar: Boolean = false, modifier: Modifier = Modifier, viewModel: MonthViewModel = hiltViewModel(), ) { + // Opens on the focused date's month, and hands its selection back as it moves. + val focus = LocalViewFocus.current + EnterOnFocus(viewModel) { viewModel.goToDate(it) } val state by viewModel.state.collectAsStateWithLifecycle() val continuousState by viewModel.continuousState.collectAsStateWithLifecycle() val month by viewModel.month.collectAsStateWithLifecycle() @@ -218,10 +282,41 @@ fun MonthScreen( derivedStateOf { if (dimCompleted) nowState.value else null } } - val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() val scrolling = viewStyle.isScrolling + var pendingScrollMonth by remember { mutableStateOf(null) } + var scrollRequestId by remember { mutableIntStateOf(0) } + + // The paged and split styles' months. Made afresh whenever a paged style + // comes on screen, opening on the month the scrolling style was showing: a + // pager that was never laid out can't be scrolled to follow it meanwhile. + val anchorPage = monthIndexOf(month) + val pagerState = key(scrolling) { + rememberPagerState(initialPage = anchorPage) { continuousMonthCount() } + } + val pageSpec = rememberCalendarPageSpec() + val reduceMotion = rememberReduceMotion() + // A swipe moves the pager and the anchor follows once it settles; anything + // else that moves the anchor has the pager follow it instead. + LaunchedEffect(anchorPage, scrolling) { + if (scrolling || pagerState.currentPage == anchorPage || pagerState.isScrollInProgress) { + return@LaunchedEffect + } + pagerState.jumpToPage(anchorPage, reduceMotion, pageSpec) + } + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled) + } + // Keep the shared focus on the visible month or its selected day. + LaunchedEffect(viewModel) { + snapshotFlow { month to selectedDate }.drop(1).collect { (visible, selected) -> + val current = focus?.date + focus?.date = selected ?: current?.takeIf { + it.year == visible.year && it.month == visible.month + } ?: LocalDate(visible.year, visible.month, 1) + } + } val dense = viewStyle == MonthViewStyle.Dense // Today, from whichever state is driving; the clock only covers the first @@ -260,7 +355,8 @@ fun MonthScreen( } } } - val titleMonth = if (scrolling) visibleMonth else month + // The month under the finger, so the title turns over mid-swipe, not after it. + val titleMonth = if (scrolling) visibleMonth else yearMonthForIndex(pagerState.currentPage) // Feed the visible range back so the sliding data window can follow. Both // styles report *months*, whatever they index by — one window serves both. @@ -292,12 +388,21 @@ fun MonthScreen( // back there rather than staying on the month you were looking at. LaunchedEffect(listState, scrolling, dense, weekStart) { if (!scrolling) return@LaunchedEffect - snapshotFlow { visibleMonth } + snapshotFlow { visibleMonth to pendingScrollMonth } .distinctUntilChanged() - .collect { viewModel.syncScrollMonth(it) } + .collect { (visible, pending) -> + if (pending == null || pending == visible) { + viewModel.syncScrollMonth(visible) + if (pending == visible) pendingScrollMonth = null + } + } } val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month) + var splitExpanded by rememberSaveable(viewStyle) { + mutableStateOf(viewStyle == MonthViewStyle.Paged) + } + var todayPulse by remember { mutableIntStateOf(0) } // Continuous names the month on the block's own sticky header, so the bar // carries the year instead of repeating it two lines further down. Dense has @@ -313,181 +418,161 @@ fun MonthScreen( } } - // Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide). - var slideDir by remember { mutableIntStateOf(0) } - - val goNext = { - slideDir = 1 - viewModel.goToNext() + // Straight to the pager as well as the anchor: a tap mid-fling must still + // land, and the anchor hasn't caught up with the fling yet to tell the + // difference. The continuous stream scrolls to the month instead. + val jumpToMonth: (YearMonth) -> Unit = { ym -> + scope.launch { pagerState.jumpToPage(monthIndexOf(ym), reduceMotion, pageSpec) } } - 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). The continuous stream - // scrolls to today's week instead — there is nothing to slide. - val jumpToToday = { + val jumpToToday: () -> Unit = { + todayPulse++ if (scrolling) { - scope.launch { - listState.animateScrollToItem( - if (dense) weekIndexOf(today, weekStart) - else itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))), - ) - } - Unit - } else { - slideDir = when (val s = state) { - is MonthUiState.Success -> - if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1 - else -> 0 - } + val targetMonth = YearMonth(today.year, today.month) + pendingScrollMonth = targetMonth + val requestId = ++scrollRequestId viewModel.goToToday() - } - } - // Drawer jump-to-date: slide from the side the target month lies on. - val jumpToDate: (LocalDate) -> Unit = { target -> - if (scrolling) { scope.launch { - listState.animateScrollToItem( - if (dense) weekIndexOf(LocalDate(target.year, target.month, 1), weekStart) - else itemIndexForMonth(monthIndexOf(YearMonth(target.year, target.month))), - ) - } - } else { - slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1 - viewModel.goToDate(target) - } - } - // Selecting a day in the split grid can cross into a neighbour month — a - // tapped leading/trailing day follows to its own month. Point the slide the - // way the month is actually moving, so the incoming page travels the right - // direction instead of reusing whatever the last swipe left in slideDir. - val selectDay: (LocalDate) -> Unit = { date -> - val target = YearMonth(date.year, date.month) - if (target != month) slideDir = if (target < month) -1 else 1 - viewModel.selectDate(date) - } - - ModalNavigationDrawer( - drawerState = drawerState, - // Open only via the menu button — edge-swipe would fight the month swipe. - gesturesEnabled = drawerState.isOpen, - drawerContent = { - CalendarDrawer( - currentView = selectedView, - currentDate = LocalDate(titleMonth.year, titleMonth.month, 1), - 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 = { - MonthTopBar( - title = topBarTitle, - shortTitle = topBarShortTitle, - titleDate = LocalDate(titleMonth.year, titleMonth.month, 1), - 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 = !isOnCurrentMonth && !todayInToolbar, - todayText = stringResource(R.string.month_today_action), - onToday = jumpToToday, - onCreate = { - // Split has a selected day; the other styles anchor on - // today when its month is shown, else the 1st. - onCreateEvent( - when { - viewStyle == MonthViewStyle.Split -> selectedDate - isOnCurrentMonth -> today - else -> LocalDate(titleMonth.year, titleMonth.month, 1) - }, - null, - ) - }, - ) - }, - ) { innerPadding -> - // Hoisted above every style's grid: a dragged chip is drawn in an - // overlay, clear of the week row's and the list viewport's clips. - val chipDrag = rememberMonthDragController() - // Undo moves the event back, so the chip travels back too. The - // signal comes from the write, not from the chip that offers it. - OnUndoStarted { chipDrag.beginUndo() } - Box( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - ) { - Column(modifier = Modifier.fillMaxSize()) { - WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers) - CompositionLocalProvider( - LocalDimCutoff provides dimCutoff, - LocalMonthDrag provides chipDrag, - ) { - if (scrolling) { - ContinuousMonthContent( - state = continuousState, - listState = listState, - dense = dense, - showWeekNumbers = showWeekNumbers, - onRetry = jumpToToday, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - ) - } else if (viewStyle == MonthViewStyle.Split) { - SplitMonthContent( - state = state, - selected = selectedDate, - slideDir = slideDir, - showWeekNumbers = showWeekNumbers, - onSwipeNext = goNext, - onSwipePrev = goPrev, - onRetry = jumpToToday, - onSelectDay = selectDay, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onCreateEvent = { onCreateEvent(it, null) }, - ) - } else { - MonthContent( - state = state, - slideDir = slideDir, - showWeekNumbers = showWeekNumbers, - onSwipeNext = goNext, - onSwipePrev = goPrev, - onRetry = jumpToToday, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - ) + try { + listState.animateScrollToItem( + if (dense) weekIndexOf(today, weekStart) + else itemIndexForMonth(monthIndexOf(targetMonth)), + ) + } finally { + if (scrollRequestId == requestId) { + pendingScrollMonth = null } } } - MonthDragOverlay(chipDrag) + } else { + viewModel.goToToday() + jumpToMonth(YearMonth(today.year, today.month)) + } + } + // Drawer jump-to-date. + val jumpToDate: (LocalDate) -> Unit = { target -> + if (scrolling) { + val targetMonth = YearMonth(target.year, target.month) + pendingScrollMonth = targetMonth + val requestId = ++scrollRequestId + viewModel.goToDate(target) + scope.launch { + try { + listState.animateScrollToItem( + if (dense) weekIndexOf(LocalDate(target.year, target.month, 1), weekStart) + else itemIndexForMonth(monthIndexOf(targetMonth)), + ) + } finally { + if (scrollRequestId == requestId) { + pendingScrollMonth = null + } + } } + } else { + viewModel.goToDate(target) + jumpToMonth(YearMonth(target.year, target.month)) + } + } + + PublishChrome( + ScreenChrome( + title = topBarTitle, + shortTitle = topBarShortTitle, + titleDate = LocalDate(titleMonth.year, titleMonth.month, 1), + drawerDate = LocalDate(titleMonth.year, titleMonth.month, 1), + awayFromToday = !isOnCurrentMonth, + todayText = stringResource(R.string.month_today_action), + ), + ScreenActions( + onToday = jumpToToday, + onJumpToDate = jumpToDate, + onCreate = { + // Split has a selected day; the other styles anchor on today when + // its month is shown, else the 1st. + onCreateEvent( + when { + (viewStyle == MonthViewStyle.Split || + (viewStyle == MonthViewStyle.Paged && !splitExpanded)) -> selectedDate + ?: if (isOnCurrentMonth) today else LocalDate(titleMonth.year, titleMonth.month, 1) + isOnCurrentMonth -> today + else -> LocalDate(titleMonth.year, titleMonth.month, 1) + }, + null, + ) + }, + ), + ) + + ScreenBody(modifier = modifier) { innerPadding -> + // Hoisted above every style's grid: a dragged chip is drawn in an + // overlay, clear of the week row's and the list viewport's clips. + val chipDrag = rememberMonthDragController() + // Undo moves the event back, so the chip travels back too. The + // signal comes from the write, not from the chip that offers it. + OnUndoStarted { chipDrag.beginUndo() } + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) { + Column(modifier = Modifier.fillMaxSize()) { + WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers) + CompositionLocalProvider( + LocalDimCutoff provides dimCutoff, + LocalMonthDrag provides chipDrag, + LocalTodayPulse provides todayPulse, + LocalOpenWeek provides onOpenWeek, + ) { + if (scrolling) { + ContinuousMonthContent( + state = continuousState, + listState = listState, + dense = dense, + showWeekNumbers = showWeekNumbers, + onRetry = jumpToToday, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + ) + } else if (viewStyle == MonthViewStyle.Split || viewStyle == MonthViewStyle.Paged) { + SplitMonthContent( + state = state, + selected = selectedDate, + initiallyExpanded = viewStyle == MonthViewStyle.Paged, + expanded = splitExpanded, + onExpandedChange = { splitExpanded = it }, + pagerState = pagerState, + weekStart = weekStart, + monthState = viewModel::month, + showWeekNumbers = showWeekNumbers, + onPaneSwipeNext = { viewModel.stepSplitPane(1) }, + onPaneSwipePrev = { viewModel.stepSplitPane(-1) }, + onClearSelection = viewModel::clearSelection, + onRetry = jumpToToday, + // A tapped leading/trailing day follows to its own + // month, pager and all — straight away, or a swipe + // still settling would land and take the tap back. + onSelectDay = { date -> + viewModel.selectDate(date) + jumpToMonth(YearMonth(date.year, date.month)) + }, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = { onCreateEvent(it, null) }, + ) + } else { + MonthContent( + state = state, + pagerState = pagerState, + weekStart = weekStart, + monthState = viewModel::month, + showWeekNumbers = showWeekNumbers, + onRetry = jumpToToday, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + ) + } + } + } + MonthDragOverlay(chipDrag) } } } @@ -595,41 +680,110 @@ private fun MonthDragOverlay(controller: MonthDragController) { @Composable private fun MonthContent( state: MonthUiState, - slideDir: Int, + pagerState: PagerState, + weekStart: DayOfWeek, + monthState: (YearMonth, DayOfWeek) -> StateFlow, showWeekNumbers: Boolean, - onSwipeNext: () -> Unit, - onSwipePrev: () -> Unit, onRetry: () -> Unit, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, ) { - val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() - val reduceMotion = rememberReduceMotion() - val swipeModifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev) - + // Loading and failure concern the provider as a whole, so they stand in for + // the pager; a page still loading its own month handles that itself. AnimatedContent( targetState = state, - modifier = Modifier.fillMaxSize().then(swipeModifier), - contentKey = { s -> - when (s) { - is MonthUiState.Success -> "success-${s.month}" - is MonthUiState.Failure -> "failure-${s.reason}" - MonthUiState.Loading -> "loading" - } - }, - transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }, - label = "month-transition", + modifier = Modifier.fillMaxSize(), + contentKey = { it::class }, + transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) }, + label = "month-state", ) { s -> when (s) { MonthUiState.Loading -> MonthGridLoading() is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) - is MonthUiState.Success -> MonthGrid( - state = s, - showWeekNumbers = showWeekNumbers, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - ) + is MonthUiState.Success -> MonthPager( + pagerState = pagerState, + weekStart = weekStart, + monthState = monthState, + modifier = Modifier.fillMaxSize(), + ) { _, page -> + MonthGrid( + state = page, + showWeekNumbers = showWeekNumbers, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + ) + } + } + } +} + +/** + * The paged and split styles' months as pages that follow the finger and snap + * once a swipe passes half a page or is flung (#336). Only the page on screen + * carries the chip drag and the morph tags: its neighbours show some of its + * dates among their leading and trailing days, and would otherwise claim its + * drops and its shared elements. + */ +@Composable +private fun MonthPager( + pagerState: PagerState, + weekStart: DayOfWeek, + monthState: (YearMonth, DayOfWeek) -> StateFlow, + modifier: Modifier = Modifier, + loading: @Composable () -> Unit = { MonthGridLoading() }, + page: @Composable (index: Int, state: MonthUiState.Success) -> Unit, +) { + val chipDrag = LocalMonthDrag.current + HorizontalPager( + state = pagerState, + modifier = modifier, + // The months either side are composed ahead, so a swipe lands on a loaded + // month rather than watching it fill in. + beyondViewportPageCount = 1, + verticalAlignment = Alignment.Top, + // A held chip moves within its month; the page stays put under it. + userScrollEnabled = chipDrag?.isDragging != true, + flingBehavior = PagerDefaults.flingBehavior( + state = pagerState, + snapAnimationSpec = rememberCalendarPageSpec(), + ), + ) { index -> + MonthPage( + index = index, + active = index == pagerState.currentPage, + morphActive = morphPageActive(index, pagerState.settledPage), + weekStart = weekStart, + monthState = monthState, + loading = loading, + page = page, + ) + } +} + +@Composable +private fun MonthPage( + index: Int, + active: Boolean, + morphActive: Boolean, + weekStart: DayOfWeek, + monthState: (YearMonth, DayOfWeek) -> StateFlow, + loading: @Composable () -> Unit, + page: @Composable (index: Int, state: MonthUiState.Success) -> Unit, +) { + val ym = yearMonthForIndex(index) + val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle() + val chipDrag = LocalMonthDrag.current + val morph = LocalMonthMorph.current + val viewMorph = LocalViewMorph.current + CompositionLocalProvider( + LocalMonthDrag provides chipDrag.takeIf { active }, + LocalMonthMorph provides morph.takeIf { active }, + LocalViewMorph provides viewMorph.takeIf { morphActive }, + ) { + when (val s = state) { + is MonthUiState.Success -> page(index, s) + else -> loading() } } } @@ -676,62 +830,6 @@ private fun ContinuousMonthContent( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun MonthTopBar( - title: String, - shortTitle: String, - titleDate: LocalDate, - selectedView: CalendarView, - onCycleView: () -> Unit, - quickSwitchViews: List, - onOpenDrawer: () -> Unit, - onOpenSearch: () -> Unit, - onJumpToDate: (LocalDate) -> Unit, - showTodayButton: Boolean, - onToday: () -> Unit, -) { - TopAppBar( - title = { - CalendarTitleButton( - title = title, - currentDate = titleDate, - 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 internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) { val locale = currentLocale() @@ -784,7 +882,7 @@ private fun rememberWeekNumberGutter(): Dp { val style = weekNumberStyle() return remember(style, density, measurer) { val text = with(density) { measurer.measure(WEEK_NUMBER_SAMPLE, style).size.width.toDp() } - text + (WEEK_NUMBER_PADDING + CELL_GAP) * 2 + (text + (WEEK_NUMBER_PADDING + CELL_GAP) * 2).coerceAtLeast(48.dp) } } @@ -1148,88 +1246,35 @@ internal fun DenseMonthGrid( } } -/** Which way a drag went, decided once per gesture and then held. */ -private enum class DragAxis { Undecided, Horizontal, Vertical } - /** - * The month grid's drag gesture: horizontal pages the month, vertical expands or - * collapses the split style (#53). The grid doesn't follow the finger, so there is - * no distance to rubber-band against — it commits the moment the accumulated drag - * clears the threshold, once per gesture, exactly as - * [rememberCalendarPageSwipe][de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe] - * does for the week and day views. - * - * This is the month's own detector rather than that shared one because it needs - * two axes, and the axis is **locked on the first movement and held for the whole - * gesture** so a drag can page or expand but never both. Two independent - * detectors on one surface would each see their own component of a diagonal drag - * and both fire. - * - * The horizontal threshold is the shared one. The vertical is larger — swapping - * the whole layout out deserves a more deliberate pull than stepping to the next - * month. - * - * [onExpand]/[onCollapse] are null for the paged style, which leaves the vertical - * axis unclaimed: the lock still happens, so a vertical drag there does nothing - * rather than being re-read as a page turn. + * The split style's expand gesture: a downward drag trades the pane away for the + * full grid, an upward one brings it back (#53). Vertical only — the months page + * on the pager inside it, and each detector only claims its own axis at the + * slop, so a drag can page or expand but never both. Commits the moment the drag + * clears [MONTH_EXPAND_THRESHOLD], once per gesture. */ @Composable -private fun rememberMonthSwipeModifier( - onSwipeNext: () -> Unit, - onSwipePrev: () -> Unit, - onExpand: (() -> Unit)? = null, - onCollapse: (() -> Unit)? = null, -): Modifier { - val density = LocalDensity.current - val pageThreshold = with(density) { CALENDAR_SWIPE_THRESHOLD.toPx() } - val expandThreshold = with(density) { MONTH_EXPAND_THRESHOLD.toPx() } - return Modifier.pointerInput(onSwipeNext, onSwipePrev, onExpand, onCollapse) { - var accum = Offset.Zero - var axis = DragAxis.Undecided +private fun rememberSplitExpandDrag(onExpand: () -> Unit, onCollapse: () -> Unit): Modifier { + val threshold = with(LocalDensity.current) { MONTH_EXPAND_THRESHOLD.toPx() } + val currentExpand by rememberUpdatedState(onExpand) + val currentCollapse by rememberUpdatedState(onCollapse) + return Modifier.pointerInput(threshold) { + var accum = 0f + // One commit per gesture: without this a long drag would keep re-firing + // every time the accumulator crossed the threshold again. var fired = false - detectDragGestures( + detectVerticalDragGestures( onDragStart = { - accum = Offset.Zero - axis = DragAxis.Undecided + accum = 0f fired = false }, - onDragEnd = { - accum = Offset.Zero - axis = DragAxis.Undecided - fired = false - }, - onDragCancel = { - accum = Offset.Zero - axis = DragAxis.Undecided - fired = false - }, - onDrag = { _, drag -> + onVerticalDrag = { _, drag -> accum += drag - if (axis == DragAxis.Undecided) { - // Ties go horizontal, keeping paging the default reading of an - // ambiguous drag as it was before the vertical axis existed. - axis = if (abs(accum.x) >= abs(accum.y)) { - DragAxis.Horizontal - } else { - DragAxis.Vertical - } - } - // Both axes commit the instant the drag clears their threshold, - // rather than on release — see [rememberCalendarPageSwipe], which - // does the same for the week and day views. if (!fired) { - val commit = when (axis) { - DragAxis.Horizontal -> when { - accum.x < -pageThreshold -> onSwipeNext - accum.x > pageThreshold -> onSwipePrev - else -> null - } - DragAxis.Vertical -> when { - accum.y > expandThreshold -> onExpand - accum.y < -expandThreshold -> onCollapse - else -> null - } - DragAxis.Undecided -> null + val commit = when { + accum > threshold -> currentExpand + accum < -threshold -> currentCollapse + else -> null } if (commit != null) { fired = true @@ -1246,13 +1291,16 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp /** * Split style content: the compact grid keeps the month swipe, the pane below it - * lists whatever day is selected — and a downward drag trades the pane away for - * the full paged grid, an upward one brings it back (#53). + * lists the month's agenda or the selected day — and a downward drag trades the + * pane away for the full paged grid, an upward one brings it back (#53). The + * paged style is the same surface opened the other way up (#111). * - * The grid slides between months like the paged style, and stands only as many - * rows tall as its own month spans (#162). A swipe between a five-row month and - * a six-row one therefore moves the pane by a row as well as swapping the grid; - * the row it hands back is worth more to the pane than a still edge is. + * One pager holds whole months — grid, handle and pane — and each page switches + * between its two faces itself. An expand therefore morphs inside a page that + * stays put: with a pager per face, every expand swapped one pager for another, + * and whether the pieces paired up came down to frame timing. + * + * The grid stands only as many rows tall as its own month spans (#162). * * Expansion is deliberately **not** a stored preference. It is a way to look at * the month you are on, not a fourth style; persisted, someone would expand it @@ -1264,11 +1312,17 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp @Composable private fun SplitMonthContent( state: MonthUiState, - selected: LocalDate, - slideDir: Int, + selected: LocalDate?, + initiallyExpanded: Boolean, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + pagerState: PagerState, + weekStart: DayOfWeek, + monthState: (YearMonth, DayOfWeek) -> StateFlow, showWeekNumbers: Boolean, - onSwipeNext: () -> Unit, - onSwipePrev: () -> Unit, + onPaneSwipeNext: () -> Unit, + onPaneSwipePrev: () -> Unit, + onClearSelection: () -> Unit, onRetry: () -> Unit, onSelectDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit, @@ -1277,175 +1331,258 @@ private fun SplitMonthContent( ) { val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() - var expanded by rememberSaveable { mutableStateOf(false) } - // Back collapses before it does anything else. Expanding replaces the whole - // screen, so it is a place you can be, and every other place in the app can - // be backed out of. Declared deeper than CalendarHost's view-stack handler, - // which is what makes it win while it is enabled. - BackHandler(enabled = expanded) { expanded = false } - // The swipe wraps the grid rather than living inside it: mid-transition - // there are two grids, and the gesture belongs to neither. The pane is left - // out of it — it scrolls and is full of tappable rows. - val swipeModifier = rememberMonthSwipeModifier( - onSwipeNext = onSwipeNext, - onSwipePrev = onSwipePrev, - onExpand = { expanded = true }, - onCollapse = { expanded = false }, + val haptics = LocalHapticFeedback.current + BackHandler(enabled = expanded != initiallyExpanded) { + onExpandedChange(initiallyExpanded) + } + val expandDrag = rememberSplitExpandDrag( + onExpand = { + if (!expanded) { + onExpandedChange(true) + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + } + }, + onCollapse = { + if (expanded) { + onExpandedChange(false) + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + } + }, ) + // In the split style a tap in the expanded grid picks the day and drops back, + // which gives the expanded month a job — a chooser you dip into — rather than + // a mode you can get stranded in. The collapse then runs with the selection + // already set, so the pane arrives showing the day you picked. The paged + // style's grid is its home, so a tap there opens the day (#111). + val onPickDay: (LocalDate) -> Unit = if (initiallyExpanded) { + onOpenDay + } else { + { + onSelectDay(it) + onExpandedChange(false) + } + } + // The view switch's own shared scope: a SharedTransitionLayout nested in the + // host's would be a second lookahead scope. Null under reduced motion. + val viewMorph = LocalViewMorph.current + val shared = viewMorph?.shared + val chipDrag = LocalMonthDrag.current when (state) { MonthUiState.Loading -> MonthGridLoading() is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry) - is MonthUiState.Success -> SharedTransitionLayout(Modifier.fillMaxSize()) { + is MonthUiState.Success -> HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + // The months either side are composed ahead, so a swipe lands on a + // loaded month rather than watching it fill in. + beyondViewportPageCount = 1, + verticalAlignment = Alignment.Top, + // A held chip moves within its month; the page stays put under it. + userScrollEnabled = chipDrag?.isDragging != true, + flingBehavior = PagerDefaults.flingBehavior( + state = pagerState, + snapAnimationSpec = rememberCalendarPageSpec(), + ), + ) { index -> + val ym = yearMonthForIndex(index) + val pageState by remember(ym, weekStart) { monthState(ym, weekStart) } + .collectAsStateWithLifecycle() + val page = pageState as? MonthUiState.Success ?: return@HorizontalPager + // Only the settled page carries the drag and the morph tags: its + // neighbours repeat some of its dates and would claim them. + val active = index == pagerState.settledPage + // The selection only ever lies in the anchor month. + val pageSelected = selected?.takeIf { it.year == ym.year && it.month == ym.month } AnimatedContent( targetState = expanded, modifier = Modifier.fillMaxSize(), - // Both branches fill the same box — the grid grows into exactly the + // Both faces fill the same page — the grid grows into exactly the // room the pane gives up — so there is no size change to contain. - // The travel between them is the shared elements' job, not a slide. + // The travel between them is the shared elements' job. transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) }, - label = "split-expand-transition", + label = "split-expand", ) { isExpanded -> - CompositionLocalProvider( - // Null under reduced motion: nothing is tagged, nothing - // travels, and the cross-fade above is the whole transition. - LocalMonthMorph provides if (reduceMotion) { - null + // The view switch's morph stays off a face until it has fully + // entered (#184): both faces tag the same events for it. Switched + // off rather than removed — adding and dropping the tags rebuilt + // every bar's modifier chain around the expand morph, which left + // bars standing still on later collapses. + val faceTransition = transition + val faceViewMorph = remember(viewMorph, active, faceTransition) { + viewMorph?.takeIf { active }?.gated { + faceTransition.currentState == EnterExitState.Visible + } + } + val monthMorph = remember(active, reduceMotion, shared, this) { + if (active && !reduceMotion && shared != null) { + MonthMorphScope(shared, this) } else { - MonthMorphScope(this@SharedTransitionLayout, this@AnimatedContent) - }, + null + } + } + CompositionLocalProvider( + LocalMonthDrag provides chipDrag.takeIf { active }, + LocalViewMorph provides faceViewMorph, + LocalMonthMorph provides monthMorph, ) { - SplitMonthBody( - expanded = isExpanded, - state = state, - selected = selected, - slideDir = slideDir, - showWeekNumbers = showWeekNumbers, - swipeModifier = swipeModifier, - onSelectDay = onSelectDay, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onCreateEvent = onCreateEvent, - onSetExpanded = { expanded = it }, - ) + if (isExpanded) { + SplitMonthExpanded( + state = page, + selected = pageSelected, + showWeekNumbers = showWeekNumbers, + expandDrag = expandDrag, + onPickDay = onPickDay, + // A tapped chip is asking for that event, not for its + // day, so it opens the detail from here too rather + // than collapsing (#187). + onEventClick = onEventClick, + onCollapse = { onExpandedChange(false) }, + ) + } else { + // The pane is still invisible for the first stretch of a + // collapse, so it is built once the grid has mostly + // landed, not on the morph's opening frames. + var paneReady by remember { + mutableStateOf(reduceMotion || transition.currentState == EnterExitState.Visible) + } + LaunchedEffect(Unit) { + if (!paneReady) { + delay(SPLIT_PANE_COMPOSE_DELAY_MILLIS) + paneReady = true + } + } + SplitMonthCollapsed( + state = page, + paneReady = paneReady, + selected = pageSelected, + showWeekNumbers = showWeekNumbers, + expandDrag = expandDrag, + onSelectDay = onSelectDay, + onPaneSwipeNext = onPaneSwipeNext, + onPaneSwipePrev = onPaneSwipePrev, + onClearSelection = onClearSelection, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + onExpand = { onExpandedChange(true) }, + // The pane waits for the grid to settle, and clears + // out before it grows, so the two never overlap. + paneModifier = if (reduceMotion) { + Modifier + } else { + Modifier.animateEnterExit( + enter = fadeIn( + tween(SPLIT_PANE_FADE_IN_MILLIS, SPLIT_PANE_FADE_IN_DELAY_MILLIS, SPLIT_PANE_EASING), + ), + exit = fadeOut(tween(SPLIT_PANE_FADE_OUT_MILLIS)), + ) + }, + ) + } } } } } } -/** The two faces of the split style, sharing one set of morph tags. */ -@Composable -private fun SplitMonthBody( - expanded: Boolean, - state: MonthUiState.Success, - selected: LocalDate, - slideDir: Int, - showWeekNumbers: Boolean, - swipeModifier: Modifier, - onSelectDay: (LocalDate) -> Unit, - onOpenDay: (LocalDate) -> Unit, - onEventClick: (EventInstance) -> Unit, - onCreateEvent: (LocalDate) -> Unit, - onSetExpanded: (Boolean) -> Unit, -) { - if (expanded) { - SplitMonthExpanded( - state = state, - selected = selected, - slideDir = slideDir, - showWeekNumbers = showWeekNumbers, - swipeModifier = swipeModifier, - // A tap in the expanded grid picks the day and drops back, which - // gives the expanded month a job — a chooser you dip into — rather - // than a mode you can get stranded in. The collapse then runs with - // the selection already set, so the pane arrives showing the day - // you picked. - onPickDay = { - onSelectDay(it) - onSetExpanded(false) - }, - // A tapped chip is asking for that event, not for its day, so it - // opens the detail from here too rather than collapsing (#187). - onEventClick = onEventClick, - onCollapse = { onSetExpanded(false) }, - ) - } else { - SplitMonthCollapsed( - state = state, - selected = selected, - slideDir = slideDir, - showWeekNumbers = showWeekNumbers, - swipeModifier = swipeModifier, - onSelectDay = onSelectDay, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onCreateEvent = onCreateEvent, - onExpand = { onSetExpanded(true) }, - ) - } -} - -/** The split style at rest: compact grid, handle, then the selected day's events. */ +/** The split style at rest: compact grid, handle, then the agenda or the selected day. */ @Composable private fun SplitMonthCollapsed( state: MonthUiState.Success, - selected: LocalDate, - slideDir: Int, + paneReady: Boolean, + selected: LocalDate?, showWeekNumbers: Boolean, - swipeModifier: Modifier, + expandDrag: Modifier, onSelectDay: (LocalDate) -> Unit, + onPaneSwipeNext: () -> Unit, + onPaneSwipePrev: () -> Unit, + onClearSelection: () -> Unit, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, onCreateEvent: (LocalDate) -> Unit, onExpand: () -> Unit, + paneModifier: Modifier = Modifier, ) { - val slideSpec = rememberCalendarSlideSpec() - val fadeSpec = rememberCalendarFadeSpec() - val reduceMotion = rememberReduceMotion() - Column(Modifier.fillMaxSize()) { // Grid and handle drag as one surface — the handle is what advertises the // gesture, so it has to answer to it as well as to a tap. The pane is // outside: it scrolls, and is full of tappable rows. - Column(swipeModifier) { - AnimatedContent( - // The selection travels *with* the state so each page keeps its - // own. Read from outside, both pages would show the incoming - // one, and paging visibly threw the marker across the outgoing - // grid — onto the new month's 1st, which the old grid still - // shows among its trailing days — before the new page arrived. - targetState = state to selected, - // Keyed on the month alone, so a provider notification refreshing - // the month you are on — or a tap moving the selection within it - // — updates in place instead of sliding. - contentKey = { (s, _) -> s.month }, - transitionSpec = { - calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) - }, - label = "split-month-transition", - ) { (s, sel) -> - SplitMonthGrid( - state = s, - selected = sel, - showWeekNumbers = showWeekNumbers, - onSelectDay = onSelectDay, - ) - } + Column(expandDrag) { + SplitMonthGrid( + state = state, + selected = selected, + showWeekNumbers = showWeekNumbers, + onSelectDay = onSelectDay, + modifier = Modifier.height(splitGridHeight(state.weeks.size)), + ) SplitExpandHandle(expanded = false, onToggle = onExpand) } - SplitDayPane( - date = selected, - today = state.today, - // Null, not empty: the selection moves to the new month before - // its data arrives, and a missing key means "not loaded yet". - // Passing an empty list would claim the day was free. - events = state.instancesByDay[selected], - zone = state.zone, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onCreateEvent = onCreateEvent, - modifier = Modifier.weight(1f).fillMaxWidth(), + if (!paneReady) { + Spacer(Modifier.weight(1f)) + } else if (selected == null) { + // The agenda pages with the month, on the pager itself. + Box(Modifier.weight(1f).fillMaxWidth().then(paneModifier)) { + SplitMonthAgendaPane( + state = state, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + ) + } + } else { + // A selected day steps a day at a time instead, so the swipe is + // taken before the pager sees it. + Column( + Modifier.weight(1f).fillMaxWidth() + .then(paneModifier) + .then(rememberSplitPaneSwipe(onPaneSwipeNext, onPaneSwipePrev)), + ) { + InputChip( + selected = true, + onClick = onClearSelection, + label = { Text(agendaDayLabel(selected, state.today)) }, + trailingIcon = { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.month_split_clear_filter), + ) + }, + modifier = Modifier.padding(start = 16.dp), + ) + SplitDayPane( + date = selected, + today = state.today, + events = state.instancesByDay[selected], + zone = state.zone, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + modifier = Modifier.weight(1f).fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun rememberSplitPaneSwipe(onNext: () -> Unit, onPrev: () -> Unit): Modifier { + val threshold = with(LocalDensity.current) { 24.dp.toPx() } + val currentNext by rememberUpdatedState(onNext) + val currentPrev by rememberUpdatedState(onPrev) + return Modifier.pointerInput(threshold) { + var distance = 0f + var fired = false + detectHorizontalDragGestures( + onDragStart = { distance = 0f; fired = false }, + onHorizontalDrag = { change, drag -> + change.consume() + distance += drag + if (!fired && kotlin.math.abs(distance) >= threshold) { + fired = true + if (distance < 0f) currentNext() else currentPrev() + } + }, ) } } @@ -1463,36 +1600,23 @@ private fun SplitMonthCollapsed( private fun SplitMonthExpanded( state: MonthUiState.Success, /** Marked nowhere here; it only anchors the outline's morph. */ - selected: LocalDate, - slideDir: Int, + selected: LocalDate?, showWeekNumbers: Boolean, - swipeModifier: Modifier, + expandDrag: Modifier, onPickDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, onCollapse: () -> Unit, ) { - val slideSpec = rememberCalendarSlideSpec() - val fadeSpec = rememberCalendarFadeSpec() - val reduceMotion = rememberReduceMotion() - // The whole screen drags here, handle included — there is no pane to keep out // of it, and the handle is the obvious thing to reach for on the way back. - Column(Modifier.fillMaxSize().then(swipeModifier)) { - AnimatedContent( - targetState = state to selected, - modifier = Modifier.weight(1f), - contentKey = { (s, _) -> s.month }, - transitionSpec = { - calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) - }, - label = "split-expanded-month-transition", - ) { (s, sel) -> + Column(Modifier.fillMaxSize().then(expandDrag)) { + Box(Modifier.weight(1f)) { MonthGrid( - state = s, + state = state, showWeekNumbers = showWeekNumbers, onOpenDay = onPickDay, onEventClick = onEventClick, - selected = sel, + selected = selected, ) } SplitExpandHandle(expanded = true, onToggle = onCollapse) @@ -1543,7 +1667,16 @@ private fun SplitExpandHandle( * it's roughly a third of a paged row — which is the point: the space it gives * up goes to the day pane below. */ -private val SPLIT_ROW_HEIGHT = 46.dp +private val SPLIT_ROW_HEIGHT = 48.dp + +/** Vertical padding and row gap of [SplitMonthGrid], which [splitGridHeight] pays for. */ +private val SPLIT_GRID_VERTICAL_PADDING = 4.dp +private val SPLIT_ROW_GAP = 2.dp + +/** How tall [SplitMonthGrid] stands for a month spanning [rows] weeks. */ +private fun splitGridHeight(rows: Int): Dp = + SPLIT_ROW_HEIGHT * rows + SPLIT_ROW_GAP * (rows - 1).coerceAtLeast(0) + + SPLIT_GRID_VERTICAL_PADDING * 2 private val SPLIT_DOT_SIZE = 5.dp // Dots are capped by SPLIT_DOT_LANES, not a constant of their own: they stand for // the paged grid's lanes, so the two caps have to be the same number or a dot @@ -1557,6 +1690,13 @@ private val SPLIT_HANDLE_WIDTH = 32.dp private val SPLIT_HANDLE_HEIGHT = 4.dp private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp +/** The pane below the split grid fades in once the grid's morph has mostly landed. */ +private const val SPLIT_PANE_FADE_IN_DELAY_MILLIS = 300 +private const val SPLIT_PANE_COMPOSE_DELAY_MILLIS = 250L +private const val SPLIT_PANE_FADE_IN_MILLIS = 250 +private const val SPLIT_PANE_FADE_OUT_MILLIS = 100 +private val SPLIT_PANE_EASING = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) + /** * The split style's grid (#53): the month compressed to day numbers and event * dots, with the selected day listed underneath by [SplitDayPane]. @@ -1573,20 +1713,23 @@ private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp @Composable internal fun SplitMonthGrid( state: MonthUiState.Success, - selected: LocalDate, + selected: LocalDate?, showWeekNumbers: Boolean, onSelectDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, ) { val dark = isSystemInDarkTheme() val month = state.month + val hasToday = state.today.year == month.year && state.today.month == month.month + val ripple = rememberTodayRipple(active = hasToday) Column( modifier = modifier .fillMaxWidth() - .padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), + .padding(horizontal = AppBarSpacing.Inset, vertical = SPLIT_GRID_VERTICAL_PADDING), + verticalArrangement = Arrangement.spacedBy(SPLIT_ROW_GAP), ) { state.weeks.forEach { week -> + val todayCol = week.days.indexOf(state.today).takeIf { hasToday && it >= 0 } Row(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT)) { if (showWeekNumbers) { WeekNumberGutter( @@ -1617,6 +1760,8 @@ internal fun SplitMonthGrid( // page transition. isSelected = day == selected && inMonth, inMonth = inMonth, + relativeToToday = todayCol?.let { col - it }, + rippleProgress = { ripple.value }, dark = dark, onClick = { onSelectDay(day) }, modifier = Modifier.weight(1f).fillMaxHeight(), @@ -1643,6 +1788,8 @@ private fun SplitDayCell( isToday: Boolean, isSelected: Boolean, inMonth: Boolean, + relativeToToday: Int?, + rippleProgress: () -> Float, dark: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, @@ -1651,6 +1798,7 @@ private fun SplitDayCell( // the whole cell, which read as a second state on top of the day rather than // as a mark on it — and fought today's own circle when they coincided. val background = when { + isToday && inMonth -> todayCellColor() inMonth -> MaterialTheme.colorScheme.surfaceContainer else -> MaterialTheme.colorScheme.surfaceContainerLow } @@ -1683,6 +1831,7 @@ private fun SplitDayCell( Box( modifier = modifier .padding(horizontal = CELL_GAP, vertical = 1.dp) + .todayRipple(relativeToToday, rippleProgress) .selectable(selected = isSelected, onClick = onClick), contentAlignment = Alignment.TopCenter, ) { @@ -1808,6 +1957,54 @@ private fun SplitDots( } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SplitMonthAgendaPane( + state: MonthUiState.Success, + onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, + onCreateEvent: (LocalDate) -> Unit, +) { + val start = agendaStartForMonth(state.month, state.today) + val days = state.instancesByDay.keys + .filter { it >= start && it.year == state.month.year && it.month == state.month.month } + .filter { state.instancesByDay[it].orEmpty().isNotEmpty() } + .toMutableList() + .apply { if (start !in this) add(start) } + .sorted() + val dimCutoff = LocalDimCutoff.current + val listState = key(state.month) { rememberLazyListState() } + LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) { + days.forEach { day -> + val events = state.instancesByDay[day].orEmpty() + stickyHeader(key = "header-$day") { + AgendaDayHeader(date = day, today = state.today, onOpenDay = onOpenDay) + } + if (events.isEmpty()) { + item(key = "empty-$day") { + AgendaEmptyDayRow( + text = stringResource(R.string.month_split_no_events), + onClick = { onCreateEvent(day) }, + ) + } + } else { + itemsIndexed(events, key = { _, event -> "$day-${event.instanceId}" }) { index, event -> + AgendaEventRow( + event = event, + day = day, + zone = state.zone, + position = positionOf(index, events.size), + dimmed = dimCutoff != null && event.hasEnded(dimCutoff), + modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day)), + onClick = { onEventClick(event) }, + ) + } + } + item(key = "gap-$day") { Spacer(Modifier.height(8.dp)) } + } + } +} + /** * The selected day's events, in the agenda's own row vocabulary so the two * surfaces read as one app. The date header opens the full Day view, matching @@ -1871,7 +2068,7 @@ internal fun SplitDayPane( // An event added to or removed from the day you are // already looking at moves its neighbours rather // than teleporting them. - modifier = animateItemMotion(), + modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day)), ) } } @@ -2031,6 +2228,8 @@ private fun MonthWeekRow( selected: LocalDate? = null, ) { val dark = isSystemInDarkTheme() + val todayCol = week.days.indexOf(today).takeIf { it >= 0 && inMonth(today) } + val ripple = rememberTodayRipple(active = todayCol != null) val overflowRow = rememberOverflowRowHeight() val laneCap = week.laneCapFor(rowHeight, overflowRow) val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 @@ -2150,15 +2349,17 @@ private fun MonthWeekRow( // Spanning bars draw on top of these, bridging cells, so they still read // as one continuous event. Row(Modifier.matchParentSize()) { - week.days.forEach { d -> + week.days.forEachIndexed { col, d -> Box( Modifier .weight(1f) .fillMaxHeight() .padding(horizontal = CELL_GAP, vertical = 1.dp) .morphElement(MonthMorphKey.Cell(d)) + .todayRipple(todayCol?.let { col - it }) { ripple.value } .background( color = when { + d == today && inMonth(d) -> todayCellColor() inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer // A blanked cell draws nothing at all: it is // the gap that shows where the month starts. @@ -2173,7 +2374,7 @@ private fun MonthWeekRow( Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) { Row(Modifier.fillMaxWidth()) { - week.days.forEach { d -> + week.days.forEachIndexed { col, d -> if (blankOutside && !inMonth(d)) { Spacer(Modifier.weight(1f).height(DAY_NUMBER_HEIGHT)) } else { @@ -2190,7 +2391,8 @@ private fun MonthWeekRow( }, modifier = Modifier .weight(1f) - .morphElement(MonthMorphKey.DayNumber(d)), + .morphElement(MonthMorphKey.DayNumber(d)) + .todayRipple(todayCol?.let { col - it }) { ripple.value }, ) } } @@ -2246,6 +2448,17 @@ private fun MonthWeekRow( instanceId = span.event.instanceId, ), ) + // A blanked day is drawn again in its own + // month's block, which owns the tag. + .then( + week.days[span.startCol].let { start -> + if (blankOutside && !inMonth(start)) { + Modifier + } else { + Modifier.morphEvent(ViewMorphKey.event(span.event, start)) + } + }, + ) .width(colW * cols) .height(EVENT_ROW_HEIGHT) .padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp), @@ -2303,8 +2516,16 @@ private fun MonthWeekRow( y = EVENT_ROW_HEIGHT * freeSlots[i], ) .morphBounds(MonthMorphKey.Event(d, ev.instanceId)) + .then( + if (blankOutside && !inMonth(d)) { + Modifier + } else { + Modifier.morphEvent(ViewMorphKey.event(ev, d)) + }, + ) .width(colW) .height(EVENT_ROW_HEIGHT) + .todayRipple(todayCol?.let { col - it }) { ripple.value } .padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp), ) } @@ -2538,8 +2759,13 @@ private fun monthChipDragModifier( private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) { val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() } val label = stringResource(R.string.week_number_label) + val openLabel = stringResource(R.string.month_open_week, weekNumber) + val onOpenWeek = LocalOpenWeek.current Box( modifier = modifier + .then(if (onOpenWeek != null) Modifier.clickable(onClickLabel = openLabel) { + onOpenWeek(weekStart) + } else Modifier) .padding(horizontal = CELL_GAP, vertical = 1.dp) .background(MaterialTheme.colorScheme.secondaryContainer, CELL_SHAPE) .semantics { contentDescription = "$label $weekNumber" }, @@ -2568,6 +2794,7 @@ private fun DayNumberCell( if (isToday) { Box( modifier = Modifier + .morphDay(date) .size(DAY_NUMBER_HEIGHT) .background(MaterialTheme.colorScheme.primary, CircleShape), contentAlignment = Alignment.Center, @@ -2588,6 +2815,7 @@ private fun DayNumberCell( maxLines = 1, overflow = TextOverflow.Visible, softWrap = false, + modifier = Modifier.morphDay(date), ) } else { Text( @@ -2595,6 +2823,7 @@ private fun DayNumberCell( style = MaterialTheme.typography.labelMedium, color = if (inMonth) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.morphDay(date), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index 91238235..cd227711 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -11,7 +11,9 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.calendarListFailure +import de.jeanlucmakiola.calendula.ui.common.PageStateCache import de.jeanlucmakiola.calendula.domain.isDeclined +import de.jeanlucmakiola.calendula.domain.eventDisplayComparator import de.jeanlucmakiola.calendula.ui.week.coversDay import de.jeanlucmakiola.calendula.ui.week.layoutAllDay import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays @@ -25,6 +27,9 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.map +import kotlin.math.abs import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.DateTimeUnit @@ -44,6 +49,9 @@ import kotlin.time.Clock import kotlin.time.Instant import javax.inject.Inject +/** How many months either side of the requested one [MonthViewModel.month] keeps cached. */ +private const val MONTH_CACHE_SPAN = 2 + @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class MonthViewModel @Inject constructor( @@ -87,6 +95,21 @@ class MonthViewModel @Inject constructor( private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month)) val month: StateFlow = _month + /** The pager page the anchor month sits on, for the paged and split styles. */ + val anchorPage: StateFlow = _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 = combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) } .flatMapLatest { (ym, ws, style) -> @@ -94,22 +117,48 @@ class MonthViewModel @Inject constructor( // Split draw, so under a scrolling style this query is a month // of provider work for a view that isn't on screen. if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading) - val range = monthGridRange(ym, ws, zone) - combine( - repository.calendars(), - repository.instances(range), - ) { calendars, instances -> - buildState(ym, ws, calendars, instances) - } + month(ym, ws).filterNot { it is MonthUiState.Loading } } .catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) } - .flowOn(io) .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), initialValue = MonthUiState.Loading, ) + private val months = PageStateCache, MonthUiState>( + scope = viewModelScope, + initial = MonthUiState.Loading, + // Far enough to cover the pages either side and a swipe back, so paging + // through a year doesn't keep a year of queries around. A week-start + // change lays every month out afresh. + keep = { (cached, cachedWs), (requested, ws) -> + cachedWs == ws && abs(monthIndexOf(cached) - monthIndexOf(requested)) <= MONTH_CACHE_SPAN + }, + ) { (ym, ws) -> + combine( + repository.calendars(), + repository.instances(monthGridRange(ym, ws, zone)), + ) { calendars, instances -> + buildState(ym, ws, calendars, instances) + } + .catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) } + .flowOn(io) + } + + /** + * Month [ym] laid out with weeks starting on [ws], shared between its pager + * page and [state]. Main thread only. + */ + fun month(ym: YearMonth, ws: DayOfWeek): StateFlow = months.get(ym to ws) + + /** The pager came to rest on [page]; follow it unless it is already the anchor's. */ + fun onPageSettled(page: Int) { + if (monthIndexOf(_month.value) == page) return + _month.value = yearMonthForIndex(page) + realignSelection() + } + // --- Continuous + Dense styles (#38) ---------------------------------- // // These scroll through every month there is, so they can't load "a month" — @@ -244,12 +293,10 @@ class MonthViewModel @Inject constructor( // --- Split style (#53) ------------------------------------------------ // - // The first selected-day concept in the app: the other views drill straight - // into a date, while the split style keeps one selected and lists it below - // the grid. + // Null means the full-month agenda; a date narrows the pane to that day. - private val _selectedDate = MutableStateFlow(todayDate) - val selectedDate: StateFlow = _selectedDate + private val _selectedDate = MutableStateFlow(null) + val selectedDate: StateFlow = _selectedDate /** * Select [date], following it to its month if it sits in the grid's leading @@ -257,33 +304,33 @@ class MonthViewModel @Inject constructor( * silently list a date the grid isn't pointing at. */ fun selectDate(date: LocalDate) { - _selectedDate.value = date val target = YearMonth(date.year, date.month) + _selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date if (target != _month.value) _month.value = target } - /** - * Move the selection with the month: today if the new month holds it, else - * its 1st. Leaving the old date selected would list a day the grid no longer - * shows. - */ + fun clearSelection() { _selectedDate.value = null } + + /** A pane swipe walks days when filtered, months when showing the agenda. */ + fun stepSplitPane(days: Int) { + val selected = _selectedDate.value + if (selected == null) { + _month.value = yearMonthForIndex(monthIndexOf(_month.value) + days) + } else { + val next = selected.plus(days, DateTimeUnit.DAY) + _selectedDate.value = next + _month.value = YearMonth(next.year, next.month) + } + } + + /** Moving the grid to another month restores its full agenda. */ private fun realignSelection() { - _selectedDate.value = selectionForMonth(_month.value, todayDate) - } - - fun goToPrev() { - _month.value = _month.value.minus(1, DateTimeUnit.MONTH) - realignSelection() - } - - fun goToNext() { - _month.value = _month.value.plus(1, DateTimeUnit.MONTH) - realignSelection() + _selectedDate.value = null } fun goToToday() { _month.value = YearMonth(todayDate.year, todayDate.month) - _selectedDate.value = todayDate + _selectedDate.value = null } /** @@ -292,13 +339,12 @@ class MonthViewModel @Inject constructor( * otherwise sit wherever paged navigation last left it (today's month, on a * fresh open) — and a switch to a paged style, or a reseed of the other * scrolling style's list, would jump there instead of holding position. The - * selection is realigned alongside it so that landing on the Split style - * shows a live day in the month on screen rather than a stale, off-month one. + * selection is cleared alongside it so Split opens the visible month's agenda. */ fun syncScrollMonth(ym: YearMonth) { if (_month.value == ym) return _month.value = ym - _selectedDate.value = selectionForMonth(ym, todayDate) + _selectedDate.value = null } /** Jump to the month containing [date] (drawer jump-to-date). */ @@ -461,11 +507,7 @@ internal fun instancesByDay( days.associateWith { day -> instances .filter { it.coversDay(day, zone) } - .sortedWith( - compareBy { it.isDeclined } - .thenByDescending { it.isAllDay } - .thenBy { it.start }, - ) + .sortedWith(eventDisplayComparator(zone)) } /** @@ -506,11 +548,10 @@ private const val WINDOW_EDGE = 2 internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0) /** - * Which day the split style should select when the grid lands on [month]: - * [today] when the month holds it, otherwise the 1st. Pure so the rule can be - * tested without standing up a view model and a provider behind it. + * First day shown in the split agenda: today in the current month, otherwise + * the first of the visible month. */ -internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate = +internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate = if (YearMonth(today.year, today.month) == month) { today } else { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt index caf14e40..defdd8b7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt @@ -18,11 +18,11 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS +import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount import de.jeanlucmakiola.calendula.ui.common.PickerDescription import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame import de.jeanlucmakiola.calendula.ui.common.icon -import de.jeanlucmakiola.calendula.ui.common.labelRes -import de.jeanlucmakiola.calendula.ui.day.DayViewPreview +import de.jeanlucmakiola.calendula.ui.common.label import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.descriptionRes @@ -67,14 +67,19 @@ internal fun ViewStep( height = PREVIEW_HEIGHT, ) CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT) - CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT) + CalendarView.MultiDay -> WeekViewPreview( + weekStart = weekStart, + height = PREVIEW_HEIGHT, + dayCount = LocalMultiDayCount.current, + ) + CalendarView.Day -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT, dayCount = 1) CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT) } } IMPLEMENTED_VIEWS.forEachIndexed { index, view -> val isSelected = view == choice.defaultView GroupedRow( - title = stringResource(view.labelRes), + title = view.label(), position = positionOf(index, IMPLEMENTED_VIEWS.size), selected = isSelected, leading = { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 2dde1abb..84539050 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -126,6 +126,7 @@ fun SearchScreen( onBack: () -> Unit, onEventClick: (EventInstance) -> Unit, modifier: Modifier = Modifier, + visible: Boolean = true, viewModel: SearchViewModel = hiltViewModel(), ) { val query by viewModel.query.collectAsStateWithLifecycle() @@ -184,7 +185,7 @@ fun SearchScreen( BackHandler(enabled = inSelection) { viewModel.clearSelection() } Scaffold( - modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection), + modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection, visible = visible), containerColor = MaterialTheme.colorScheme.surface, topBar = { SearchTopBar( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt index 40f1704f..5774e5a8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt @@ -76,6 +76,7 @@ internal fun AppearanceScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean = true, ) { var showTheme by remember { mutableStateOf(false) } var showBrandFont by remember { mutableStateOf(false) } @@ -96,6 +97,7 @@ internal fun AppearanceScreen( title = stringResource(R.string.settings_section_appearance), onBack = onBack, predictiveBack = true, + visible = visible, ) { // Theme & colour GroupedRow( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt index a9674aa6..f7d0b393 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt @@ -41,6 +41,7 @@ internal fun EventFormScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean = true, ) { var showDefaultDuration by remember { mutableStateOf(false) } // The calendar whose duration-override picker is open, if any. @@ -53,6 +54,7 @@ internal fun EventFormScreen( title = stringResource(R.string.settings_section_event_form), onBack = onBack, predictiveBack = true, + visible = visible, ) { SettingsHint(stringResource(R.string.settings_form_fields_hint)) Spacer(Modifier.height(8.dp)) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt index cc778059..7fe559f0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt @@ -63,6 +63,7 @@ internal fun NotificationsScreen( viewModel: SettingsViewModel, onBack: () -> Unit, onOpenSpecialDates: () -> Unit, + visible: Boolean = true, ) { val context = LocalContext.current val launcher = rememberLauncherForActivityResult( @@ -92,6 +93,7 @@ internal fun NotificationsScreen( title = stringResource(R.string.settings_section_notifications), onBack = onBack, predictiveBack = true, + visible = visible, ) { GroupedRow( title = stringResource(R.string.settings_reminders), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index eddae090..a24d9e3a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -2,9 +2,7 @@ package de.jeanlucmakiola.calendula.ui.settings import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -66,6 +64,7 @@ import de.jeanlucmakiola.floret.crash.CrashReportDialog import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.openIssueTracker import de.jeanlucmakiola.floret.crash.submitCrashReport +import de.jeanlucmakiola.floret.identity.predictiveBackExit import de.jeanlucmakiola.floret.locale.AppLanguage /** The settings sub-screens reached from the hub's category rows. */ @@ -85,11 +84,14 @@ fun SettingsScreen( onManageCalendars: () -> Unit, onOpenBackup: () -> Unit, modifier: Modifier = Modifier, + visible: Boolean = true, viewModel: SettingsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() var section by rememberSaveable { mutableStateOf(null) } val slideSpec = rememberCalendarSlideSpec() + // Leaves from the predictive-back preview rather than sliding off at full size. + val overlayExit = predictiveBackExit() Box( modifier = modifier @@ -101,61 +103,63 @@ fun SettingsScreen( onOpenSection = { section = it }, onManageCalendars = onManageCalendars, onOpenBackup = onOpenBackup, + visible = visible, ) AnimatedVisibility( visible = section == SettingsSection.Appearance, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { - AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }) + AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Appearance) } AnimatedVisibility( visible = section == SettingsSection.Views, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { - ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null }) + ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Views) } AnimatedVisibility( visible = section == SettingsSection.EventForm, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { - EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null }) + EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.EventForm) } AnimatedVisibility( visible = section == SettingsSection.Notifications, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { NotificationsScreen( state = state, viewModel = viewModel, onBack = { section = null }, onOpenSpecialDates = { section = SettingsSection.SpecialDates }, + visible = section == SettingsSection.Notifications, ) } AnimatedVisibility( visible = section == SettingsSection.SpecialDates, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { - SpecialDatesScreen(viewModel = viewModel, onBack = { section = null }) + SpecialDatesScreen(viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.SpecialDates) } AnimatedVisibility( visible = section == SettingsSection.Widgets, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { - WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }) + WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets) } AnimatedVisibility( visible = section == SettingsSection.Licences, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = 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, onManageCalendars: () -> Unit, onOpenBackup: () -> Unit, + visible: Boolean, ) { - CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack, predictiveBack = true) { + CollapsingScaffold( + title = stringResource(R.string.settings_title), + onBack = onBack, + predictiveBack = true, + visible = visible, + ) { // Card and support row are one grouped block, so the call to action // continues the container instead of sitting inside it as a button. Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index d3f6f9d9..16111c17 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.widget.WidgetSize @@ -34,6 +35,8 @@ data class SettingsUiState( val timeFormat: TimeFormatPref = TimeFormatPref.AUTO, /** Whether the week/day timeline seats each hour in its own cell (v2.11). */ val showHourGrid: Boolean = false, + /** Days the multi-day view shows side by side (#193). */ + val multiDayCount: Int = SettingsPrefs.DEFAULT_MULTI_DAY_COUNT, /** How the Agenda screen treats events that already ended today. */ val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW, /** Whether the month/week grids fade events that have already finished. */ @@ -62,6 +65,7 @@ data class SettingsUiState( val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged, /** How tall an hour is drawn in the week and day timelines (#56). */ val timelineScale: TimelineScale = TimelineScale.Regular, + val timelineHours: TimelineHours = TimelineHours(), /** Order of the views in the navigation drawer (#24); every view is always listed. */ val drawerViewOrder: List = IMPLEMENTED_VIEWS, /** Optional event-form fields shown by default (rest behind "more fields"). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index f6a0105d..75ebca18 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -36,6 +36,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings @@ -148,11 +149,12 @@ class SettingsViewModel @Inject constructor( combine( prefs.todayButtonInToolbar, prefs.dragToReschedule, - ::Pair, + prefs.multiDayCount, + ::Triple, ), - ) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove) -> + ) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove, multiDay) -> DisplayToggles( - hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove, + hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove, multiDay, ) }, ) { view, screenRange, widgetRange, timeFormat, toggles -> @@ -164,6 +166,7 @@ class SettingsViewModel @Inject constructor( softenColors = toggles.softenColors, todayButtonInToolbar = toggles.todayButtonInToolbar, dragToReschedule = toggles.dragToReschedule, + multiDayCount = toggles.multiDayCount, ) }, combine( @@ -178,9 +181,11 @@ class SettingsViewModel @Inject constructor( prefs.drawerViewOrder, prefs.monthViewStyle, prefs.widgetSize, - prefs.timelineScale, - ) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale -> - ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale) + combine(prefs.timelineScale, prefs.timelineHours, ::Pair), + ) { quickSwitch, drawer, monthStyle, widgetSize, (timelineScale, timelineHours) -> + ViewCustomization( + quickSwitch, drawer, monthStyle, widgetSize, timelineScale, timelineHours, + ) }, ) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization -> MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization) @@ -197,6 +202,7 @@ class SettingsViewModel @Inject constructor( softenColors = views.softenColors, todayButtonInToolbar = views.todayButtonInToolbar, dragToReschedule = views.dragToReschedule, + multiDayCount = views.multiDayCount, agendaShowRangeBar = misc.showRangeBar, autofocusEventTitle = misc.autofocusEventTitle, pastEventDisplay = misc.pastEventDisplay, @@ -206,6 +212,7 @@ class SettingsViewModel @Inject constructor( monthViewStyle = misc.viewCustomization.monthViewStyle, widgetSize = misc.viewCustomization.widgetSize, timelineScale = misc.viewCustomization.timelineScale, + timelineHours = misc.viewCustomization.timelineHours, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -298,6 +305,7 @@ class SettingsViewModel @Inject constructor( val softenColors: Boolean, val todayButtonInToolbar: Boolean, val dragToReschedule: Boolean, + val multiDayCount: Int, ) private data class DisplayToggles( @@ -307,6 +315,7 @@ class SettingsViewModel @Inject constructor( val softenColors: Boolean, val todayButtonInToolbar: Boolean, val dragToReschedule: Boolean, + val multiDayCount: Int, ) private data class MiscSettings( @@ -323,6 +332,7 @@ class SettingsViewModel @Inject constructor( val monthViewStyle: MonthViewStyle, val widgetSize: WidgetSize, val timelineScale: TimelineScale, + val timelineHours: TimelineHours, ) /** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */ @@ -532,6 +542,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setTimeFormat(pref) } } + fun setMultiDayCount(count: Int) { + viewModelScope.launch { prefs.setMultiDayCount(count) } + } + fun setShowHourGrid(enabled: Boolean) { viewModelScope.launch { prefs.setShowHourGrid(enabled) } } @@ -617,6 +631,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setTimelineScale(scale) } } + fun setTimelineHours(hours: TimelineHours) { + viewModelScope.launch { prefs.setTimelineHours(hours) } + } + fun setDrawerViewOrder(order: List) { viewModelScope.launch { prefs.setDrawerViewOrder(order) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt index 8933214d..0e8559bb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt @@ -46,6 +46,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride internal fun SpecialDatesScreen( viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean = true, ) { val state by viewModel.specialDatesState.collectAsStateWithLifecycle() val settings by viewModel.state.collectAsStateWithLifecycle() @@ -76,6 +77,7 @@ internal fun SpecialDatesScreen( title = stringResource(R.string.settings_section_special_dates), onBack = onBack, predictiveBack = true, + visible = visible, ) { // Paused banner: the permission was revoked after enabling. if (state.enabled && state.stalledPermission) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt index a0c988f4..22ccb1ae 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt @@ -21,10 +21,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay @@ -32,11 +34,15 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.PickerDescription +import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat +import de.jeanlucmakiola.calendula.ui.common.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.descriptionRes +import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.icon +import de.jeanlucmakiola.calendula.ui.common.label import de.jeanlucmakiola.calendula.ui.common.labelRes import de.jeanlucmakiola.calendula.ui.month.labelRes import de.jeanlucmakiola.floret.components.CollapsingScaffold @@ -62,6 +68,7 @@ internal fun ViewsScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean = true, ) { var showMonthStyle by remember { mutableStateOf(false) } var showDefaultView by remember { mutableStateOf(false) } @@ -70,11 +77,16 @@ internal fun ViewsScreen( var showPastEvents by remember { mutableStateOf(false) } var showAgendaScreenRange by remember { mutableStateOf(false) } var showTimelineScale by remember { mutableStateOf(false) } + var showMultiDayCount by remember { mutableStateOf(false) } + var showHoursStart by remember { mutableStateOf(false) } + var showHoursEnd by remember { mutableStateOf(false) } + val hours = state.timelineHours CollapsingScaffold( title = stringResource(R.string.settings_section_views), onBack = onBack, predictiveBack = true, + visible = visible, ) { val config = state.quickSwitchConfig @@ -82,7 +94,7 @@ internal fun ViewsScreen( SectionHeader(stringResource(R.string.settings_views_all_header)) GroupedRow( title = stringResource(R.string.settings_default_view), - summary = stringResource(state.defaultView.labelRes), + summary = state.defaultView.label(), position = Position.Top, onClick = { showDefaultView = true }, ) @@ -159,11 +171,56 @@ internal fun ViewsScreen( Spacer(Modifier.height(8.dp)) SectionHeader(stringResource(R.string.settings_week_day_header)) GroupedRow( - title = stringResource(R.string.settings_timeline_scale), - summary = stringResource(state.timelineScale.labelRes), + title = stringResource(R.string.settings_multi_day_count), + summary = multiDayLabel(state.multiDayCount), position = Position.Top, - onClick = { showTimelineScale = true }, + onClick = { showMultiDayCount = true }, ) + GroupedRow( + title = stringResource(R.string.settings_timeline_scale), + summary = if (hours.enabled) { + stringResource(R.string.settings_timeline_scale_by_visible_hours) + } else { + stringResource(state.timelineScale.labelRes) + }, + position = Position.Middle, + dimmed = hours.enabled, + onClick = if (hours.enabled) null else ({ showTimelineScale = true }), + ) + GroupedRow( + title = stringResource(R.string.settings_visible_hours), + summary = if (hours.enabled) { + stringResource( + R.string.settings_visible_hours_on_summary, + hourLabel(hours.startHour), + hourLabel(hours.endHour), + ) + } else { + stringResource(R.string.settings_visible_hours_summary) + }, + position = Position.Middle, + trailing = { + Switch( + checked = hours.enabled, + onCheckedChange = { viewModel.setTimelineHours(hours.copy(enabled = it)) }, + ) + }, + onClick = { viewModel.setTimelineHours(hours.copy(enabled = !hours.enabled)) }, + ) + if (hours.enabled) { + GroupedRow( + title = stringResource(R.string.settings_visible_hours_start), + summary = hourLabel(hours.startHour), + position = Position.Middle, + onClick = { showHoursStart = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_visible_hours_end), + summary = hourLabel(hours.endHour), + position = Position.Middle, + onClick = { showHoursEnd = true }, + ) + } GroupedRow( title = stringResource(R.string.settings_hour_lines), summary = stringResource(R.string.settings_hour_lines_summary), @@ -275,7 +332,7 @@ internal fun ViewsScreen( predictiveBack = true, options = IMPLEMENTED_VIEWS, selected = state.defaultView, - label = { stringResource(it.labelRes) }, + label = { it.label() }, leading = { Icon( imageVector = it.icon, @@ -337,6 +394,18 @@ internal fun ViewsScreen( onDismiss = { showPastEvents = false }, ) } + if (showMultiDayCount) { + OptionPicker( + title = stringResource(R.string.settings_multi_day_count), + header = { PickerDescription(stringResource(R.string.settings_multi_day_count_hint)) }, + predictiveBack = true, + options = SettingsPrefs.MULTI_DAY_COUNTS.toList(), + selected = state.multiDayCount, + label = { multiDayLabel(it) }, + onSelect = viewModel::setMultiDayCount, + onDismiss = { showMultiDayCount = false }, + ) + } if (showTimelineScale) { OptionPicker( title = stringResource(R.string.settings_timeline_scale), @@ -356,6 +425,30 @@ internal fun ViewsScreen( onDismiss = { showTimelineScale = false }, ) } + if (showHoursStart) { + OptionPicker( + title = stringResource(R.string.settings_visible_hours_start), + header = { PickerDescription(stringResource(R.string.settings_visible_hours_start_hint)) }, + predictiveBack = true, + options = TimelineHours.startOptions(hours.endHour), + selected = hours.startHour, + label = { hourLabel(it) }, + onSelect = { viewModel.setTimelineHours(hours.copy(startHour = it)) }, + onDismiss = { showHoursStart = false }, + ) + } + if (showHoursEnd) { + OptionPicker( + title = stringResource(R.string.settings_visible_hours_end), + header = { PickerDescription(stringResource(R.string.settings_visible_hours_end_hint)) }, + predictiveBack = true, + options = TimelineHours.endOptions(hours.startHour), + selected = hours.endHour, + label = { hourLabel(it) }, + onSelect = { viewModel.setTimelineHours(hours.copy(endHour = it)) }, + onDismiss = { showHoursEnd = false }, + ) + } if (showAgendaScreenRange) { AgendaRangePicker( title = stringResource(R.string.settings_agenda_range), @@ -384,7 +477,7 @@ private fun ViewRow( trailing: @Composable (() -> Unit)? = null, ) { GroupedRow( - title = stringResource(view.labelRes), + title = view.label(), position = position, dimmed = dimmed, minHeight = ReorderableRowHeight, @@ -449,3 +542,12 @@ internal fun pastEventDisplayLabelRes(mode: PastEventDisplay): Int = when (mode) PastEventDisplay.DIM -> R.string.settings_past_events_dim PastEventDisplay.HIDE -> R.string.settings_past_events_hide } + +@Composable +private fun multiDayLabel(count: Int): String = + pluralStringResource(R.plurals.view_multi_day, count, count) + +/** A whole hour in the app's clock convention; 24 reads as the end of the day. */ +@Composable +private fun hourLabel(hour: Int): String = + formatMinuteOfDay(hour * 60, LocalUse24HourFormat.current, currentLocale()) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt index 8aa6e344..d8301cc2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt @@ -42,6 +42,7 @@ internal fun WidgetsScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean = true, ) { var showAgendaWidgetRange by remember { mutableStateOf(false) } var showWidgetSize by remember { mutableStateOf(false) } @@ -50,6 +51,7 @@ internal fun WidgetsScreen( title = stringResource(R.string.settings_section_widgets), onBack = onBack, predictiveBack = true, + visible = visible, ) { SettingsHint(stringResource(R.string.settings_widgets_hint)) Spacer(Modifier.height(8.dp)) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometry.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometry.kt new file mode 100644 index 00000000..42a0ed4b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometry.kt @@ -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 = 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, toRange: ClosedRange, 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, 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, + val toRange: ClosedRange, + val fromLane: Int, + val toLane: Int, +) + +/** [state]'s all-day bars, standing still. */ +internal fun staticBars(state: WeekUiState.Success): List = + 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 { + 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 = eventId to start.toEpochMilliseconds() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineMorph.kt new file mode 100644 index 00000000..ae527670 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineMorph.kt @@ -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, + val toState: StateFlow, +) { + 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt new file mode 100644 index 00000000..e19ed1ee --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt @@ -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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 1867c93c..007fd06c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -1,12 +1,31 @@ package de.jeanlucmakiola.calendula.ui.week import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerDefaults +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateMapOf +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.rememberCalendarPageSpec +import de.jeanlucmakiola.calendula.ui.common.rememberViewSwitchSpec +import de.jeanlucmakiola.calendula.ui.common.jumpToPage +import de.jeanlucmakiola.calendula.ui.common.pagedHeight +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.drop 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.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -25,40 +44,42 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape 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.Surface 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.derivedStateOf import androidx.compose.runtime.key -import androidx.compose.runtime.getValue -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.BiasAlignment import androidx.compose.ui.Modifier +import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph +import de.jeanlucmakiola.calendula.ui.common.StayPut +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.flow.first +import androidx.compose.runtime.withFrameNanos +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.SideEffect +import de.jeanlucmakiola.calendula.ui.common.laneSlot +import de.jeanlucmakiola.calendula.ui.common.LaneColumn +import kotlin.math.roundToInt +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.layout.Layout import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned @@ -69,6 +90,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp @@ -81,13 +103,8 @@ 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.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING @@ -95,6 +112,7 @@ import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel import de.jeanlucmakiola.calendula.ui.common.BlockTitle import de.jeanlucmakiola.calendula.ui.common.blockTextLines import de.jeanlucmakiola.calendula.ui.common.blockTimeLines +import de.jeanlucmakiola.calendula.ui.common.rememberBlockTimeRange import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement import de.jeanlucmakiola.calendula.ui.common.ghostAlpha @@ -120,11 +138,19 @@ import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.trimmedLines import de.jeanlucmakiola.calendula.ui.common.NowLine +import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdgeBars +import de.jeanlucmakiola.calendula.ui.common.rememberOutOfViewEdges import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute -import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill -import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition +import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus +import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec -import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe +import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey +import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage +import de.jeanlucmakiola.calendula.ui.common.morphDay +import de.jeanlucmakiola.calendula.ui.common.morphEvent +import de.jeanlucmakiola.calendula.ui.common.rememberTimelineScrollState +import de.jeanlucmakiola.calendula.ui.common.LocalOpeningTimeline +import de.jeanlucmakiola.calendula.ui.common.morphPageActive import de.jeanlucmakiola.calendula.ui.common.withTitleWeight import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale @@ -142,17 +168,13 @@ 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.common.rememberCalendarSlideSpec -import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.time.isoWeekNumber -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.plus import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toLocalDateTime -import kotlin.time.Clock import java.time.format.TextStyle as JavaTextStyle import java.util.Locale @@ -162,6 +184,26 @@ private val ALL_DAY_VERTICAL_PADDING = 6.dp /** Gap between day columns; part of the column pitch a drag maps positions through. */ private val COLUMN_GAP = 2.dp +/** Breathing room between the header block and the scrolling timeline below. */ +private val TIMELINE_TOP_GAP = 8.dp +private val HEADER_TOP_PADDING = 4.dp +private val HEADER_BOTTOM_PADDING = 8.dp + +/** The header's date slot, reserved whether or not it holds today's circle. */ +private val DATE_SLOT_SIZE = 28.dp + +/** A start-aligned header cell's inset, lining its date up with the event titles below. */ +private val HEADER_START_INSET = BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING + +/** The weekday header's height, which the day view takes off to fit the week's hours (#184). */ +@Composable +internal fun weekDayHeaderHeight(): Dp { + val label = MaterialTheme.typography.labelSmall + val line = label.lineHeight.takeIf { it.isSp } ?: label.fontSize + return HEADER_TOP_PADDING + with(LocalDensity.current) { line.toDp() } + 2.dp + + DATE_SLOT_SIZE + HEADER_BOTTOM_PADDING +} + /** Total all-day strip height for a week (0 when there are no all-day events). */ internal fun WeekUiState.Success.allDayStripHeight(): Dp { if (allDaySpans.isEmpty()) return 0.dp @@ -169,25 +211,119 @@ internal fun WeekUiState.Success.allDayStripHeight(): Dp { return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2 } -@OptIn(ExperimentalMaterial3Api::class) +/** + * The day, multi-day (#193) and week views: one timeline whose columns resize + * in place when switching between them (#184), rather than three screens. + */ @Composable -fun WeekScreen( - selectedView: CalendarView, - onSelectView: (CalendarView) -> Unit, +fun TimelineScreen( + view: CalendarView, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, - onOpenSettings: () -> Unit, - onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, - quickSwitchViews: List = IMPLEMENTED_VIEWS, - drawerViewOrder: List = IMPLEMENTED_VIEWS, - todayInToolbar: Boolean = false, modifier: Modifier = Modifier, - viewModel: WeekViewModel = hiltViewModel(), + weekViewModel: WeekViewModel = hiltViewModel(), + multiDayViewModel: MultiDayViewModel = hiltViewModel(), + dayViewModel: DayViewModel = hiltViewModel(), ) { - val state by viewModel.state.collectAsStateWithLifecycle() - val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle() - val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle() + val viewModels = remember(weekViewModel, multiDayViewModel, dayViewModel) { + TimelineViewModels(weekViewModel, multiDayViewModel, dayViewModel) + } + PagedTimelineScreen( + view = view, + viewModels = viewModels, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + modifier = modifier, + ) +} + +/** Each timeline view's own pages and anchor, held side by side so a switch can show both. */ +internal class TimelineViewModels( + private val week: WeekViewModel, + private val multiDay: MultiDayViewModel, + private val day: DayViewModel, +) { + operator fun get(view: CalendarView): PagedTimelineViewModel = when (view) { + CalendarView.MultiDay -> multiDay + CalendarView.Day -> day + else -> week + } +} + +/** What sets the timeline views apart; everything else they share. */ +internal enum class TimelineKind( + val showWeekNumber: Boolean, + val todayText: Int, + /** Where a header cell sits in its column: centred, or at the start like the event titles. */ + val headerBias: Float, +) { + Week(showWeekNumber = true, todayText = R.string.week_today_action, headerBias = 0f), + + // A run of days often straddles two weeks, so no single number fits it. + MultiDay(showWeekNumber = false, todayText = R.string.week_today_action, headerBias = 0f), + Day(showWeekNumber = false, todayText = R.string.day_today_action, headerBias = -1f), + ; + + fun title(start: LocalDate, end: LocalDate, locale: Locale, currentYear: Int, abbreviated: Boolean): String = + if (this == Day) { + formatDayTitle(start, locale, currentYear, abbreviated) + } else { + formatWeekTitle(start, end, locale, currentYear, abbreviated) + } +} + +internal val CalendarView.timelineKind: TimelineKind + get() = when (this) { + CalendarView.MultiDay -> TimelineKind.MultiDay + CalendarView.Day -> TimelineKind.Day + else -> TimelineKind.Week + } + +/** How long a switch waits for the view it goes to before resizing into it without its events. */ +private const val MORPH_DATA_WAIT_MS = 150L + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PagedTimelineScreen( + view: CalendarView, + viewModels: TimelineViewModels, + onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, + onCreateEvent: (LocalDate, Int?) -> Unit, + modifier: Modifier, +) { + // The view on screen, which trails [view] while the columns resize into it. + var shown by remember { mutableStateOf(view) } + var morph by remember { mutableStateOf(null) } + // The strip height a switch ended on, for the pager it hands over to. + var handoffStrip by remember { mutableStateOf(null) } + val viewModel = viewModels[shown] + val kind = shown.timelineKind + + // Opens on the focused date, then carries it along as the pages move. + val focus = LocalViewFocus.current + val navigationVersion = focus?.navigationVersion ?: 0 + var handledNavigation by remember { mutableIntStateOf(navigationVersion) } + EnterOnFocus(viewModel) { viewModel.jumpTo(it) } + // Keyed on the view model: collected unkeyed, a switch would read the view + // before's anchor and grid for a frame and open its pager. + val loadedState by key(viewModel) { viewModel.state.collectAsStateWithLifecycle() } + // A view not shown before has nothing loaded yet; the one before it stands + // in, as its pages load their own days anyway, rather than a skeleton. + var lastState by remember { mutableStateOf(WeekUiState.Loading) } + val state = if (loadedState is WeekUiState.Loading) lastState else loadedState + SideEffect { if (loadedState !is WeekUiState.Loading) lastState = loadedState } + LaunchedEffect(shown, loadedState) { + if (loadedState is WeekUiState.Success) handoffStrip = null + } + val anchor by key(viewModel) { viewModel.anchor.collectAsStateWithLifecycle() } + val rule by key(viewModel) { viewModel.rule.collectAsStateWithLifecycle() } + val position = remember(anchor, rule) { positionOf(anchor, rule) } + val grid = position.grid + val anchorPage = position.page + val dimCompleted by key(viewModel) { viewModel.dimCompletedEvents.collectAsStateWithLifecycle() } // The instant before which an event counts as completed, or null when dimming // is off. derivedStateOf keeps the per-minute "now" from recomposing the // screen while the setting is off (it stays null regardless of the tick). @@ -196,113 +332,149 @@ fun WeekScreen( derivedStateOf { if (dimCompleted) nowState.value else null } } - val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() - val isOnCurrentWeek = when (val s = state) { - // True when today falls inside the displayed week — independent of which - // weekday the user picked as the first day. - is WeekUiState.Success -> - s.today >= s.weekStart && s.today <= s.weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY) - else -> true + // A new grid numbers the pages differently, so it gets a pager of its own, + // opened straight on the anchor's page: another view, the first day of the + // week changing, or a multi-day jump moving another date into the first column. + val pagerState = key(viewModel, grid) { + rememberPagerState(initialPage = anchorPage) { grid.pageCount } } - - // 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 WeekUiState.Success -> s.today.year - else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year - } - - // Slide direction for the week 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 WeekUiState.Success -> if (s.today < s.weekStart) -1 else 1 - else -> 0 + val pageSpec = rememberCalendarPageSpec() + val switchSpec = rememberViewSwitchSpec() + val longSwitchSpec = rememberViewSwitchSpec(long = true) + val reduceMotion = rememberReduceMotion() + // The pager leads and the anchor follows once it settles, so this only + // catches the anchor moving on its own. Snapped, since that is a correction + // rather than a move. + LaunchedEffect(pagerState, anchorPage) { + if (pagerState.currentPage != anchorPage && !pagerState.isScrollInProgress) { + pagerState.scrollToPage(anchorPage) } - viewModel.goToToday() } - // Drawer jump-to-date: slide from the side the target week lies on. - val jumpToDate: (LocalDate) -> Unit = { target -> - slideDir = if (target < weekStart) -1 else 1 - viewModel.goToDate(target) + // The page it opened on is skipped: a grid change landing in between would + // read it as a different page. + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage }.drop(1).collect { page -> + viewModel.onPageSettled(page) + focus?.let { it.date = grid.focusOnPage(it.date, page) } + } } - ModalNavigationDrawer( - drawerState = drawerState, - // Open only via the menu button — edge-swipe would fight the week swipe. - gesturesEnabled = drawerState.isOpen, - drawerContent = { - CalendarDrawer( - currentView = selectedView, - currentDate = weekStart, - 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() } - }, + // Switching to another timeline view resizes the columns into it (#184): + // from the page on screen to the target view's page around the focused + // date, then hands over to that view's pager at the page the resize ended on. + val currentShown by rememberUpdatedState(shown) + val currentPager by rememberUpdatedState(pagerState) + val currentGrid by rememberUpdatedState(grid) + val currentState by rememberUpdatedState(state) + LaunchedEffect(view, navigationVersion) { + if (navigationVersion != handledNavigation) { + handledNavigation = navigationVersion + morph = null + handoffStrip = null + viewModels[view].jumpTo(focus?.date ?: return@LaunchedEffect) + shown = view + return@LaunchedEffect + } + // A switch interrupted by another lands first, and this one starts from there. + snapshotFlow { morph }.first { it == null } + withFrameNanos { } + if (view == currentShown) return@LaunchedEffect + val from = currentShown + val fromGrid = currentGrid + val fromSpan = DaySpan(fromGrid.startForPage(currentPager.currentPage), fromGrid.dayCount) + val target = (focus?.date ?: fromSpan.start).coerceIn(fromSpan.start, fromSpan.end) + focus?.date = target + val toModel = viewModels[view] + toModel.jumpTo(target) + val to = positionOf(target, toModel.rule.value) + val toSpan = DaySpan(to.grid.startForPage(to.page), to.grid.dayCount) + val fromLoaded = viewModels[from].span(fromSpan) + val toLoaded = toModel.span(toSpan) + if (reduceMotion || currentState !is WeekUiState.Success) { + shown = view + return@LaunchedEffect + } + val switch = TimelineMorph(from.timelineKind, view.timelineKind, fromSpan, toSpan, fromLoaded, toLoaded) + try { + withTimeoutOrNull(MORPH_DATA_WAIT_MS) { toLoaded.first { it !is WeekUiState.Loading } } + morph = switch + switch.progress.animateTo(1f, if (switch.isLong) longSwitchSpec else switchSpec) + handoffStrip = (toLoaded.value as? WeekUiState.Success)?.allDayStripHeight() + shown = view + } finally { + morph = null + } + } + + // The page under the finger, so the title turns over mid-swipe, not after + // it; mid-switch, the page being switched to. + val weekStart = morph?.to?.start ?: grid.startForPage(pagerState.currentPage) + val titleKind = morph?.toKind ?: kind + val dayCount = morph?.to?.count ?: grid.dayCount + val weekEnd = weekStart.plus(dayCount - 1, kotlinx.datetime.DateTimeUnit.DAY) + + // Off the ticking clock rather than the loaded page, so it turns over at + // midnight; derived, so the per-minute tick doesn't recompose the screen. + val zone = remember { TimeZone.currentSystemDefault() } + val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } } + val isOnCurrentWeek = state !is WeekUiState.Success || (today >= weekStart && today <= weekEnd) + + // Straight to the pager: a tap mid-fling must still land, and the anchor + // hasn't caught up with the fling yet to tell the difference. A date that + // would open mid-page on this grid re-frames the pages around it instead. + val jumpToDate: (LocalDate) -> Unit = { target -> + focus?.date = target + val page = grid.pageFor(target) + if (grid is DaysGrid && grid.startForPage(page) != target) { + viewModel.jumpTo(target) + } else { + scope.launch { pagerState.jumpToPage(page, reduceMotion, pageSpec) } + } + } + val jumpToToday = { jumpToDate(today) } + + val locale = currentLocale() + val (title, shortTitle) = remember(titleKind, weekStart, weekEnd, locale, today.year) { + titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = false) to + titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = true) + } + PublishChrome( + ScreenChrome( + title = title, + shortTitle = shortTitle, + titleDate = weekStart, + drawerDate = weekStart, + awayFromToday = !isOnCurrentWeek, + todayText = stringResource(titleKind.todayText), + ), + ScreenActions( + onToday = jumpToToday, + onJumpToDate = jumpToDate, + // Anchor on today when it's in view, else the page's first day. + onCreate = { onCreateEvent(if (isOnCurrentWeek) today else weekStart, null) }, + ), + ) + + ScreenBody(modifier = modifier) { innerPadding -> + CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { + WeekContent( + state = state, + pagerState = pagerState, + grid = grid, + kind = kind, + morph = morph, + handoffStrip = handoffStrip, + span = viewModel::span, + onRetry = viewModel::goToToday, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), ) - }, - ) { - Scaffold( - modifier = modifier, - topBar = { - WeekTopBar( - weekStart = weekStart, - 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 = !isOnCurrentWeek && !todayInToolbar, - todayText = stringResource(R.string.week_today_action), - onToday = jumpToToday, - onCreate = { - // Anchor on today when it's in view, else the week's first day. - val today = Clock.System.now() - .toLocalDateTime(TimeZone.currentSystemDefault()).date - onCreateEvent(if (isOnCurrentWeek) today else weekStart, null) - }, - ) - }, - ) { innerPadding -> - CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { - WeekContent( - state = state, - slideDir = slideDir, - onSwipeNext = goNext, - onSwipePrev = goPrev, - onRetry = jumpToToday, - onEventClick = onEventClick, - onOpenDay = onOpenDay, - onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - ) - } } } } @@ -310,97 +482,327 @@ fun WeekScreen( @Composable private fun WeekContent( state: WeekUiState, - slideDir: Int, - onSwipeNext: () -> Unit, - onSwipePrev: () -> Unit, + pagerState: PagerState, + grid: PageGrid, + kind: TimelineKind, + morph: TimelineMorph?, + handoffStrip: Dp?, + span: (DaySpan) -> StateFlow, onRetry: () -> Unit, onEventClick: (EventInstance) -> Unit, onOpenDay: (LocalDate) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, ) { - val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() - val reduceMotion = rememberReduceMotion() - // Hoisted above the per-week AnimatedContent so the vertical scroll position - // survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on - // noon once, on first entry into the week view (i.e. when arriving from the - // month/day view), not on every swipe. - 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) - } + // Shared by every page and the gutter, so the vertical scroll position + // survives week-to-week swipes (e.g. 18:00 stays centred), and carried over + // from the timeline view before this one. + val stripHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp + val timelineScroll = rememberTimelineScrollState(weekDayHeaderHeight() + stripHeight + TIMELINE_TOP_GAP) + val scrollState = timelineScroll.state - // Single, hoisted all-day strip height — shared by the outgoing and incoming - // week during a swipe, so the strip slides along but never jumps in height; - // it just springs smoothly from the old to the new size. - val targetAllDayHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp - val allDayHeight by animateDpAsState( - targetValue = targetAllDayHeight, - label = "all-day-strip-height", - ) - - // Whole-page horizontal swipe. It sits one level above the timeline's - // vertical scroll: a horizontal drag only crosses *this* detector's slop, - // while a vertical drag is consumed by the inner scroll first — so the two - // gestures coexist without fighting. - val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev) - - // Above the AnimatedContent: a page change mid-drag would strand the - // floating block inside the outgoing page. + // Above the pager: 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() } + val onDrop: (TimelineDrop) -> Unit = { drop -> + move?.move( + MoveRequest( + eventId = drop.event.eventId, + beginMillis = drop.event.start.toEpochMilliseconds(), + endMillis = drop.event.end.toEpochMilliseconds(), + target = MoveTarget.Start(drop.startInstant(zone)), + ), + ) + } Box(modifier = modifier) { - AnimatedContent( - targetState = state, - modifier = Modifier.fillMaxSize().then(swipeModifier), - contentKey = { s -> + CompositionLocalProvider(LocalOpeningTimeline provides timelineScroll) { + // Loading and failure concern the provider as a whole, so they stand in + // for the pager; a page still loading its own week handles that itself. + AnimatedContent( + targetState = state, + modifier = Modifier.fillMaxSize(), + contentKey = { it::class }, + transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) }, + label = "week-state", + ) { s -> when (s) { - is WeekUiState.Success -> "success-${s.weekStart}" - is WeekUiState.Failure -> "failure-${s.reason}" - WeekUiState.Loading -> "loading" - } - }, - transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }, - label = "week-transition", - ) { s -> - when (s) { - WeekUiState.Loading -> WeekLoading() - is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) - is WeekUiState.Success -> WeekSuccess( - state = s, - scrollState = scrollState, - allDayHeight = allDayHeight, - dragController = dragController, - onEventClick = onEventClick, - onOpenDay = onOpenDay, - 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)), - ), + WeekUiState.Loading -> WeekLoading(dayCount = grid.dayCount) + is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) + is WeekUiState.Success -> if (morph != null) { + TimelineMorphFrame( + morph = morph, + today = s.today, + scrollState = scrollState, + dragController = dragController, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = onDrop, ) - }, - ) + } else WeekPager( + pagerState = pagerState, + grid = grid, + kind = kind, + span = span, + today = s.today, + initialStripHeight = handoffStrip ?: s.allDayStripHeight(), + scrollState = scrollState, + dragController = dragController, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) + } } } TimelineDragOverlay(dragController) } } +/** + * The weeks as pages that follow the finger and snap once a swipe passes half a + * page or is flung (#131), beside a gutter that stays put. + */ +@Composable +private fun WeekPager( + pagerState: PagerState, + grid: PageGrid, + kind: TimelineKind, + span: (DaySpan) -> StateFlow, + today: LocalDate, + initialStripHeight: Dp, + scrollState: ScrollState, + dragController: TimelineDragController, + onEventClick: (EventInstance) -> Unit, + onOpenDay: (LocalDate) -> Unit, + onCreateAt: (LocalDate, Int) -> Unit, + onDrop: (TimelineDrop) -> Unit, +) { + val stripHeights = remember { mutableStateMapOf() } + val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight) + WeekFrame( + weekStart = grid.startForPage(pagerState.currentPage), + showWeekNumber = kind.showWeekNumber, + allDayHeight = allDayHeight, + scrollState = scrollState, + dragController = dragController, + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + // The weeks either side are composed ahead, so a swipe lands on a + // loaded week rather than watching it fill in. + beyondViewportPageCount = 1, + // A held block moves within its week; the page stays put under it. + userScrollEnabled = !dragController.isDragging, + flingBehavior = PagerDefaults.flingBehavior( + state = pagerState, + snapAnimationSpec = rememberCalendarPageSpec(), + ), + ) { page -> + val pageSpan = DaySpan(grid.startForPage(page), grid.dayCount) + val pageState by remember(pageSpan) { span(pageSpan) }.collectAsStateWithLifecycle() + val loaded = pageState as? WeekUiState.Success + // Until its days arrive a page shows their frame without events. + val empty = remember(pageSpan, today) { emptySpan(pageSpan, today) } + val stripHeight = loaded?.allDayStripHeight() + DisposableEffect(page, stripHeight) { + if (stripHeight != null) stripHeights[page] = stripHeight + onDispose { stripHeights.remove(page) } + } + ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) { + WeekPage( + state = loaded ?: empty, + headerBias = kind.headerBias, + allDayHeight = allDayHeight, + scrollState = scrollState, + dragController = dragController, + active = page == pagerState.currentPage, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) + } + } + } +} + +/** A page with no events yet, for one whose days are still loading. */ +private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success { + val days = (0 until span.count).map { span.start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) } + return WeekUiState.Success( + weekStart = span.start, + today = today, + days = days, + allDaySpans = emptyList(), + timedByDay = emptyMap(), + ) +} + +/** + * The timeline mid-switch between two views (#184): every day of both pages, + * each column between its place on the one and on the other, with no pager + * under them. It starts and ends exactly as the two pages lay out, so the + * pagers either side hand over without a jump. + */ +@Composable +private fun TimelineMorphFrame( + morph: TimelineMorph, + today: LocalDate, + scrollState: ScrollState, + dragController: TimelineDragController, + onEventClick: (EventInstance) -> Unit, + onOpenDay: (LocalDate) -> Unit, + onCreateAt: (LocalDate, Int) -> Unit, + onDrop: (TimelineDrop) -> Unit, +) { + val fromState by morph.fromState.collectAsStateWithLifecycle() + val toState by morph.toState.collectAsStateWithLifecycle() + val from = fromState as? WeekUiState.Success + val to = toState as? WeekUiState.Success + val geometry = remember(morph) { ColumnGeometry(morph.from, morph.to) { morph.progress.value } } + val bars = remember(from, to) { morphBars(from, to) } + val timedFor: (LocalDate) -> List = remember(morph, from, to) { + { date -> + val side = if (morph.sourceOf(date) == TimelineMorph.Side.From) from else to + side?.timedByDay?.get(date).orEmpty() + } + } + val fromStrip = from?.allDayStripHeight() ?: 0.dp + val toStrip = to?.allDayStripHeight() ?: fromStrip + val animatedStrip = remember(morph, fromStrip, toStrip) { + { androidx.compose.ui.unit.lerp(fromStrip, toStrip, morph.progress.value.coerceIn(0f, 1f)) } + } + val fromBadge = if (morph.fromKind.showWeekNumber) 1f else 0f + val toBadge = if (morph.toKind.showWeekNumber) 1f else 0f + val badgeAlpha = remember(morph) { + { lerp(fromBadge, toBadge, morph.progress.value).coerceIn(0f, 1f) } + } + // The header changes its alignment once; its columns and bars move in layout. + val headerBias by remember(morph) { + derivedStateOf { + if (morph.progress.value < 0.5f) morph.fromKind.headerBias else morph.toKind.headerBias + } + } + // The events travel inside the columns here, not between views. + CompositionLocalProvider(LocalViewMorph provides null) { + WeekFrame( + weekStart = if (morph.toKind.showWeekNumber) morph.to.start else morph.from.start, + showWeekNumber = morph.fromKind.showWeekNumber || morph.toKind.showWeekNumber, + allDayHeight = fromStrip, + animatedAllDayHeight = animatedStrip, + weekNumberAlpha = badgeAlpha, + scrollState = scrollState, + dragController = dragController, + ) { + TimelinePageContent( + geometry = geometry, + today = today, + timedFor = timedFor, + bars = bars, + headerBias = headerBias, + allDayHeight = fromStrip, + animatedAllDayHeight = animatedStrip, + scrollState = scrollState, + dragController = dragController, + active = false, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) + } + } +} + +/** Read an animated strip height during measurement, without composing its contents again. */ +private fun Modifier.dynamicHeight(height: () -> Dp): Modifier = layout { measurable, constraints -> + val pixels = height().roundToPx().coerceIn(constraints.minHeight, constraints.maxHeight) + val placeable = measurable.measure(constraints.copy(minHeight = pixels, maxHeight = pixels)) + layout(placeable.width, pixels) { placeable.place(0, 0) } +} + +/** Keep a morph's expensive viewport content measured at one size while its visible edge moves. */ +private fun Modifier.stableMorphViewport(stripHeight: () -> Dp, fullHeight: IntArray): Modifier = + clipToBounds().layout { measurable, constraints -> + if (fullHeight[0] < 0) fullHeight[0] = constraints.maxHeight + stripHeight().roundToPx() + val measured = measurable.measure( + constraints.copy(minHeight = fullHeight[0], maxHeight = fullHeight[0]), + ) + layout(constraints.maxWidth, constraints.maxHeight) { measured.place(0, 0) } + } + +/** + * What stays put while the weeks swipe: the week-number badge and the hour + * gutter, with [pages] filling the rest. + */ +@Composable +private fun WeekFrame( + weekStart: LocalDate, + showWeekNumber: Boolean, + allDayHeight: Dp, + animatedAllDayHeight: (() -> Dp)? = null, + weekNumberAlpha: () -> Float = { 1f }, + scrollState: ScrollState, + dragController: TimelineDragController, + pages: @Composable () -> Unit, +) { + val gutterFullHeight = remember(animatedAllDayHeight) { intArrayOf(-1) } + Row(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.width(GUTTER_WIDTH).fillMaxHeight()) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface), + ) { + WeekNumberCell( + weekStart = weekStart, + showBadge = showWeekNumber, + modifier = if (animatedAllDayHeight == null) Modifier + else Modifier.graphicsLayer { alpha = weekNumberAlpha() }, + ) + Spacer(if (animatedAllDayHeight == null) Modifier.height(allDayHeight) + else Modifier.dynamicHeight(animatedAllDayHeight)) + } + Spacer(Modifier.height(TIMELINE_TOP_GAP)) + // Resolves the hour height off the same viewport height the pages' + // timelines have, so labels and columns agree. + BoxWithConstraints( + modifier = Modifier.fillMaxSize().then( + if (animatedAllDayHeight == null) Modifier + else Modifier.stableMorphViewport(animatedAllDayHeight, gutterFullHeight), + ), + ) { + val zoom = LocalTimelineZoom.current + val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight + else maxHeight + val hourHeight = zoom.scale.hourHeight(fitHeight) + HourGutter( + scrollState = scrollState, + hourHeight = hourHeight, + dragController = dragController, + modifier = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom), + ) + } + } + // Clipped, as the pager was: a view switch slides columns in past its edges. + Box(modifier = Modifier.weight(1f).fillMaxHeight().clipToBounds()) { pages() } + } +} + +/** A single week in its frame, without the pager — for the Settings preview. */ @Composable internal fun WeekSuccess( state: WeekUiState.Success, + showWeekNumber: Boolean = true, + headerBias: Float = 0f, scrollState: ScrollState, allDayHeight: Dp, dragController: TimelineDragController, @@ -408,6 +810,89 @@ internal fun WeekSuccess( onOpenDay: (LocalDate) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, onDrop: (TimelineDrop) -> Unit, +) { + WeekFrame( + weekStart = state.weekStart, + showWeekNumber = showWeekNumber, + allDayHeight = allDayHeight, + scrollState = scrollState, + dragController = dragController, + ) { + WeekPage( + state = state, + headerBias = headerBias, + allDayHeight = allDayHeight, + scrollState = scrollState, + dragController = dragController, + active = true, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) + } +} + +/** + * One week's page: header, all-day strip and day columns. + * + * @param active whether this is the page on screen, the only one that may + * publish the drag geometry. + */ +@Composable +private fun WeekPage( + state: WeekUiState.Success, + headerBias: Float, + allDayHeight: Dp, + scrollState: ScrollState, + dragController: TimelineDragController, + active: Boolean, + onEventClick: (EventInstance) -> Unit, + onOpenDay: (LocalDate) -> Unit, + onCreateAt: (LocalDate, Int) -> Unit, + onDrop: (TimelineDrop) -> Unit, +) { + val geometry = remember(state.weekStart, state.days.size) { + ColumnGeometry.of(DaySpan(state.weekStart, state.days.size)) + } + val bars = remember(state) { staticBars(state) } + TimelinePageContent( + geometry = geometry, + today = state.today, + timedFor = { state.timedByDay[it].orEmpty() }, + bars = bars, + headerBias = headerBias, + allDayHeight = allDayHeight, + scrollState = scrollState, + dragController = dragController, + active = active, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) +} + +/** + * A page's header, all-day strip and day columns, laid out over [geometry]: a + * single page's, or both pages' of a view switch mid-way (#184). + */ +@Composable +private fun TimelinePageContent( + geometry: ColumnGeometry, + today: LocalDate, + timedFor: (LocalDate) -> List, + bars: List, + headerBias: Float, + allDayHeight: Dp, + animatedAllDayHeight: (() -> Dp)? = null, + scrollState: ScrollState, + dragController: TimelineDragController, + active: Boolean, + onEventClick: (EventInstance) -> Unit, + onOpenDay: (LocalDate) -> Unit, + onCreateAt: (LocalDate, Int) -> Unit, + onDrop: (TimelineDrop) -> Unit, ) { Column(modifier = Modifier.fillMaxSize()) { Column( @@ -415,22 +900,26 @@ internal fun WeekSuccess( .fillMaxWidth() .background(MaterialTheme.colorScheme.surface), ) { - WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay) + WeekDayHeader(geometry = geometry, today = today, bias = headerBias, onOpenDay = onOpenDay) // This strip is painted on `surface`, so an outlined chip has to // fill with that and not with the columns' container (#230). CompositionLocalProvider( LocalChipGround provides MaterialTheme.colorScheme.surface, ) { - AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick) + AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight, + animatedHeight = animatedAllDayHeight, onEventClick = onEventClick) } } - // Breathing room between the top section and the scrolling timeline - // below. - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(TIMELINE_TOP_GAP)) Timeline( - state = state, + geometry = geometry, + today = today, + timedFor = timedFor, + allDayHeight = allDayHeight, + animatedAllDayHeight = animatedAllDayHeight, scrollState = scrollState, dragController = dragController, + active = active, onEventClick = onEventClick, onCreateAt = onCreateAt, onDrop = onDrop, @@ -438,140 +927,99 @@ internal fun WeekSuccess( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun WeekTopBar( - weekStart: LocalDate, - currentYear: Int, - selectedView: CalendarView, - onCycleView: () -> Unit, - quickSwitchViews: List, - onOpenDrawer: () -> Unit, - onOpenSearch: () -> Unit, - onJumpToDate: (LocalDate) -> Unit, - showTodayButton: Boolean, - onToday: () -> Unit, -) { - val locale = currentLocale() - val (title, shortTitle) = remember(weekStart, locale, currentYear) { - formatWeekTitle(weekStart, locale, currentYear) to - formatWeekTitle(weekStart, locale, currentYear, abbreviated = true) - } - TopAppBar( - title = { - CalendarTitleButton( - title = title, - currentDate = weekStart, - 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 WeekDayHeader( - days: List, + geometry: ColumnGeometry, today: LocalDate, + bias: Float, onOpenDay: (LocalDate) -> Unit, ) { val locale = currentLocale() - val weekStart = days.first() - val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() } - Row( + TimelineColumns( + geometry = geometry, + gap = 0.dp, modifier = Modifier .fillMaxWidth() - .padding(top = 4.dp, bottom = 8.dp, end = TIMELINE_CONTENT_END_INSET), - ) { - // Mirror the day-column layout (empty weekday line + spacer) so the - // badge lines up vertically with the date numbers. The start inset centres - // the badge on the top bar's hamburger (see GUTTER_CONTENT_START_INSET). + .padding(top = HEADER_TOP_PADDING, bottom = HEADER_BOTTOM_PADDING, end = TIMELINE_CONTENT_END_INSET), + ) { date -> + val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1) + val isToday = date == today Column( - modifier = Modifier.width(GUTTER_WIDTH).padding(start = GUTTER_CONTENT_START_INSET), - horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable { onOpenDay(date) } + .padding(start = HEADER_START_INSET * -bias), + horizontalAlignment = BiasAlignment.Horizontal(bias), ) { - Text(text = " ", style = MaterialTheme.typography.labelSmall) + // One line, as the gutter cell beside it assumes (see WeekNumberCell). + Text( + text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale), + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Clip, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) Spacer(Modifier.height(2.dp)) - WeekNumberBadge(weekNumber = weekNumber) - } - days.forEach { date -> - val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1) - val isToday = date == today - Column( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(12.dp)) - .clickable { onOpenDay(date) }, - horizontalAlignment = Alignment.CenterHorizontally, + // Always reserve the circle slot so the header height is + // identical whether or not the week contains today. + Box( + modifier = Modifier.size(DATE_SLOT_SIZE), + contentAlignment = Alignment.Center, ) { - Text( - text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(2.dp)) - // Always reserve the 28dp circle slot so the header height is - // identical whether or not the week contains today. - Box( - modifier = Modifier.size(28.dp), - contentAlignment = Alignment.Center, - ) { - if (isToday) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.fillMaxSize(), - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = date.day.toString(), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) - } + if (isToday) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.morphDay(date).fillMaxSize(), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = date.day.toString(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) } - } else { - Text( - text = date.day.toString(), - style = MaterialTheme.typography.bodyMedium, - ) } + } else { + Text( + text = date.day.toString(), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.morphDay(date), + ) } } } } } +/** + * The header's gutter cell, laid out like a day's (empty weekday line, spacer, + * date slot) so the badge lines up with the date numbers and the cell is exactly + * as tall as the header beside it. The start inset centres the badge on the top + * bar's hamburger (see GUTTER_CONTENT_START_INSET). + */ +@Composable +private fun WeekNumberCell(weekStart: LocalDate, showBadge: Boolean, modifier: Modifier = Modifier) { + val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() } + Column( + modifier = modifier + .fillMaxWidth() + .padding( + start = GUTTER_CONTENT_START_INSET, + top = HEADER_TOP_PADDING, + bottom = HEADER_BOTTOM_PADDING, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = " ", style = MaterialTheme.typography.labelSmall) + Spacer(Modifier.height(2.dp)) + Box(modifier = Modifier.height(DATE_SLOT_SIZE), contentAlignment = Alignment.TopCenter) { + if (showBadge) WeekNumberBadge(weekNumber = weekNumber) + } + } +} + /** Calendar-week badge shown in the header gutter, deliberately set apart with a * filled box and bold number — at the month grid's size, so the two agree (#213). */ @Composable @@ -594,8 +1042,10 @@ private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) { @Composable private fun AllDayStrip( - state: WeekUiState.Success, + geometry: ColumnGeometry, + bars: List, height: Dp, + animatedHeight: (() -> Dp)? = null, onEventClick: (EventInstance) -> Unit, ) { val dark = isSystemInDarkTheme() @@ -605,40 +1055,49 @@ private fun AllDayStrip( .fillMaxWidth() // Height is hoisted + animated so it slides and resizes smoothly; // padding sits inside it so the content area is lanes * row height. - .height(height) + .then(if (animatedHeight == null) Modifier.height(height) else Modifier.dynamicHeight(animatedHeight)) .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 columns. - Spacer(Modifier.width(GUTTER_WIDTH)) // Span bars are positioned absolutely so a multi-day event is one // connected bar across columns rather than a chip per day. clipToBounds // keeps bars from spilling out while the height animates. - BoxWithConstraints( + Layout( + content = { + bars.forEach { bar -> + key(bar.event.eventId, bar.event.start) { + AllDayBar( + event = bar.event, + dark = dark, + onClick = { onEventClick(bar.event) }, + modifier = Modifier + .morphEvent(ViewMorphKey.event(bar.event, bar.keyDate)) + .padding(horizontal = 1.dp, vertical = 1.dp), + ) + } + } + }, modifier = Modifier .weight(1f) .fillMaxHeight() .clipToBounds(), - ) { - val colWidth = maxWidth / 7 - state.allDaySpans.forEach { span -> - val spanCols = span.endCol - span.startCol + 1 - AllDayBar( - event = span.event, - dark = dark, - onClick = { onEventClick(span.event) }, - modifier = Modifier - .offset( - x = colWidth * span.startCol, - y = ALL_DAY_ROW_HEIGHT * span.lane, - ) - .width(colWidth * spanCols) - .height(ALL_DAY_ROW_HEIGHT) - .padding(horizontal = 1.dp, vertical = 1.dp), - ) + ) { measurables, constraints -> + val width = constraints.maxWidth.toFloat() + val rowPx = ALL_DAY_ROW_HEIGHT.toPx() + val progress = geometry.progress() + val placed = measurables.mapIndexed { i, measurable -> + val bar = bars[i] + val extent = geometry.extent(bar.fromRange, bar.toRange, width, 0f) + val left = extent.left.roundToInt() + val barWidth = (extent.right.roundToInt() - left).coerceAtLeast(0) + val y = (lerp(bar.fromLane.toFloat(), bar.toLane.toFloat(), progress) * rowPx).roundToInt() + Triple(measurable.measure(Constraints.fixed(barWidth, rowPx.roundToInt())), left, y) + } + layout(constraints.maxWidth, constraints.maxHeight) { + placed.forEach { (placeable, x, y) -> placeable.placeRelative(x, y) } } } } @@ -679,13 +1138,19 @@ private fun AllDayBar( @Composable private fun Timeline( - state: WeekUiState.Success, + geometry: ColumnGeometry, + today: LocalDate, + timedFor: (LocalDate) -> List, + allDayHeight: Dp, + animatedAllDayHeight: (() -> Dp)? = null, scrollState: ScrollState, dragController: TimelineDragController, + active: Boolean, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, onDrop: (TimelineDrop) -> Unit, ) { + val fullViewportHeight = remember(geometry) { intArrayOf(-1) } val dark = isSystemInDarkTheme() val zoom = LocalTimelineZoom.current val density = LocalDensity.current @@ -699,75 +1164,91 @@ private fun Timeline( // 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, day header and all-day strip. - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val hourHeight = zoom.scale.hourHeight(maxHeight) + // bar, day header and all-day strip. The strip is given back, so the scale + // holds while paging and matches the day view's (#184). + BoxWithConstraints( + modifier = Modifier.fillMaxSize().then( + if (animatedAllDayHeight == null) Modifier + else Modifier.stableMorphViewport(animatedAllDayHeight, fullViewportHeight), + ), + ) { + val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight + val hourHeight = zoom.scale.hourHeight(fitHeight) 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 columns 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 (not just at the - // day's start/end). - Row(modifier = Modifier.fillMaxSize().then(pinch)) { - // Hour gutter (scrolls in sync with the day columns). Same start inset - // as the header badge so the labels sit under it and on the hamburger. - HourGutter( - scrollState = scrollState, - hourHeight = hourHeight, - dragController = dragController, - ) - // Day columns: rounded, clipped scroll viewport (permanent corners). - Box( + // The pinch sits above the scroll viewport: it has to outrank the + // vertical scroll, and it does that by watching the initial pass, which + // only reaches it if it is the viewport's ancestor. + val pinch = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom) + // What a block's text is fitted to: the column it will end up in once a + // view switch is past half way, so it reflows once rather than per frame. + val textColumns by remember(geometry) { + derivedStateOf { if (geometry.progress() >= 0.5f) geometry.to.count else geometry.from.count } + } + val columnWidth = (maxWidth - TIMELINE_CONTENT_END_INSET + COLUMN_GAP) / textColumns - COLUMN_GAP + // What the scroll shows, known before it is laid out: a view switch + // picks what travels on its first frame. + val opening = LocalOpeningTimeline.current + val viewportPx = with(density) { maxHeight.toPx() } + val visibleViewport: () -> Float = remember(animatedAllDayHeight, viewportPx, density) { + { viewportPx - (animatedAllDayHeight?.let { with(density) { it().toPx() } } ?: 0f) } + } + val contentPx = with(density) { totalHeight.toPx() } + val visibleTop: () -> Float = remember(opening, scrollState, visibleViewport, contentPx) { + { opening?.top(visibleViewport(), contentPx) ?: scrollState.value.toFloat() } + } + // Scrolls on the same state as the gutter and the other pages, so they + // all stay aligned. A static, rounded-clipped window — the content + // scrolls inside it, so the soft corners are permanent at any scroll + // position (not just at the day's start/end). + Box( + modifier = Modifier + .fillMaxSize() + .then(pinch) + .padding(end = TIMELINE_CONTENT_END_INSET) + .clip(RoundedCornerShape(16.dp)) + .verticalScroll(scrollState) + .onGloballyPositioned { if (active) dragController.geometry.viewport = it }, + ) { + TimelineColumns( + geometry = geometry, + gap = COLUMN_GAP, modifier = Modifier - .weight(1f) - .fillMaxHeight() - .padding(end = TIMELINE_CONTENT_END_INSET) - .clip(RoundedCornerShape(16.dp)) - .verticalScroll(scrollState) - .onGloballyPositioned { dragController.geometry.viewport = it }, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(totalHeight) - // The scrolling content itself, so its root position - // already folds in the scroll offset. - .onGloballyPositioned { coords -> - val gap = with(density) { COLUMN_GAP.toPx() } - dragController.geometry.let { - it.grid = coords - it.scroll = scrollState - it.hourPx = with(density) { hourHeight.toPx() } - it.blockInsetPx = blockInsetPx - it.columnGapPx = gap - it.columnWidthPx = (coords.size.width + gap) / state.days.size - it.days = state.days - it.isRtl = isRtl - } - }, - horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP), - ) { - state.days.forEach { day -> - DayColumnCard( - blocks = state.timedByDay[day].orEmpty(), - dark = dark, - date = day, - today = state.today, - hourHeight = hourHeight, - dragController = dragController, - onEventClick = onEventClick, - onCreateAt = onCreateAt, - onDrop = onDrop, - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - ) - } - } + .fillMaxWidth() + .height(totalHeight) + // The scrolling content itself, so its root position + // already folds in the scroll offset. Only the page on + // screen publishes it: the ones either side are laid out too. + .onGloballyPositioned { coords -> + if (!active) return@onGloballyPositioned + val gap = with(density) { COLUMN_GAP.toPx() } + dragController.geometry.let { + it.grid = coords + it.scroll = scrollState + it.hourPx = with(density) { hourHeight.toPx() } + it.blockInsetPx = blockInsetPx + it.columnGapPx = gap + it.columnWidthPx = (coords.size.width + gap) / geometry.to.count + it.days = geometry.dates + it.isRtl = isRtl + } + }, + ) { day -> + DayColumnCard( + blocks = timedFor(day), + dark = dark, + date = day, + today = today, + hourHeight = hourHeight, + columnWidth = columnWidth, + visibleTop = visibleTop, + viewportHeight = visibleViewport, + dragController = dragController, + onEventClick = onEventClick, + onCreateAt = onCreateAt, + onDrop = onDrop, + scrollState = scrollState, + modifier = Modifier.fillMaxHeight(), + ) } } } @@ -780,13 +1261,19 @@ private fun DayColumnCard( date: LocalDate, today: LocalDate, hourHeight: Dp, + columnWidth: Dp, + visibleTop: () -> Float, + viewportHeight: () -> Float, dragController: TimelineDragController, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, onDrop: (TimelineDrop) -> Unit, + scrollState: ScrollState, modifier: Modifier = Modifier, ) { - val hourPx = with(LocalDensity.current) { hourHeight.toPx() } + val density = LocalDensity.current + val hourPx = with(density) { hourHeight.toPx() } + val morphing = LocalViewMorph.current != null val showHourGrid = LocalShowHourGrid.current val zone = remember { TimeZone.currentSystemDefault() } // Tells a settled drop when this column has caught up with it. @@ -799,6 +1286,8 @@ private fun DayColumnCard( // them, or taps land on the scale the column had before the zoom (#148). val currentHourPx = rememberUpdatedState(hourPx) val currentOnCreateAt = rememberUpdatedState(onCreateAt) + val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx) + val currentEdges = rememberUpdatedState(edges) Card( // Plain rectangular columns — the soft corners come from the outer // rounded scroll viewport, so inner rounding would look odd at the edges. @@ -816,7 +1305,8 @@ private fun DayColumnCard( ), modifier = modifier, ) { - BoxWithConstraints( + Box(Modifier.fillMaxSize()) { + LaneColumn( modifier = Modifier .fillMaxSize() // The hour cells sit over the column background but under the @@ -826,6 +1316,7 @@ private fun DayColumnCard( // blocks are consumed by their own handler first. Snaps to hour. .pointerInput(date) { detectTapGestures { offset -> + if (currentEdges.value.tap(offset.y)) return@detectTapGestures currentOnCreateAt.value( date, tappedMinuteOfDay(offset.y, currentHourPx.value), @@ -833,7 +1324,6 @@ private fun DayColumnCard( } }, ) { - 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 @@ -842,14 +1332,13 @@ private fun DayColumnCard( 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, + xFraction = block.lane.toFloat() / block.laneCount, + widthFraction = 1f / block.laneCount, y = top, - width = laneWidth, height = height, ) // Where this block is cut at midnight, which decides both @@ -864,11 +1353,22 @@ private fun DayColumnCard( val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom) val blockHeight = (place.height - topInset - bottomInset) .coerceAtLeast(0.dp) + // Only a block wholly on screen travels in a view switch: the + // rest would cross the header on their way. They fade with + // their screen, and the other view leaves them be too. + val travels = morphing && rememberInViewport( + visibleTop = visibleTop, + viewportHeight = viewportHeight, + top = with(density) { (top + topInset).toPx() }, + bottom = with(density) { (top + height - bottomInset).toPx() }, + ) + val morphKey = ViewMorphKey.event(block.event, date) + StayPut(morphKey, stays = morphing && !travels) EventBlock( block = block, dark = dark, height = blockHeight, - width = place.width, + width = columnWidth / block.laneCount, date = date, cuts = cuts, topInset = topInset, @@ -876,8 +1376,9 @@ private fun DayColumnCard( onClick = { onEventClick(block.event) }, onDrop = onDrop, modifier = Modifier - .offset(x = place.x, y = place.y + topInset) - .width(place.width) + .laneSlot(place) + .offset(y = place.y + topInset) + .then(if (travels) Modifier.morphEvent(morphKey) else Modifier) .height(blockHeight) .padding(horizontal = BLOCK_OUTER_INSET), ) @@ -888,9 +1389,24 @@ private fun DayColumnCard( NowLine(date = date, hourHeight = hourHeight) } } + OutOfViewEdgeBars( + edges = edges, + scrollState = scrollState, + dark = dark, + ) + } } } +/** Whether [top]..[bottom], in pixels down the scrolled content, lies wholly in its viewport. */ +@Composable +private fun rememberInViewport(visibleTop: () -> Float, viewportHeight: () -> Float, top: Float, bottom: Float): Boolean { + val inView by remember(visibleTop, viewportHeight, top, bottom) { + derivedStateOf { visibleTop().let { top >= it && bottom <= it + viewportHeight() } } + } + return inView +} + @Composable private fun EventBlock( block: TimedBlock, @@ -921,6 +1437,11 @@ private fun EventBlock( val showTime = block.laneCount == 1 && metrics.available >= metrics.titleLine + metrics.timeLine val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2 + val shownTime = rememberBlockTimeRange( + minToHm(block.startMin, use24Hour, locale), + minToHm(block.endMin, use24Hour, locale), + textWidth, + ) // A short block drops the title rather than serving a horizontally sliced // one: half a letter reads as a rendering fault, while a bare colour chip // reads as what it is — an event too brief to label. Tap still opens it, and @@ -946,7 +1467,7 @@ private fun EventBlock( ) 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 timeMaxLines = if (showTime) blockTimeLines(shownTime, textWidth, spare) else 1 val dimCutoff = LocalDimCutoff.current val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff) val zone = remember { TimeZone.currentSystemDefault() } @@ -1005,7 +1526,7 @@ private fun EventBlock( } if (showTime) { BlockTimeLabel( - label = timeLabel, + label = shownTime, color = paint.secondaryInk, maxLines = timeMaxLines, ) @@ -1014,20 +1535,24 @@ private fun EventBlock( } } +private val SKELETON_HEADER_HEIGHT = 36.dp +private val SKELETON_HEADER_PADDING = 8.dp + @Composable -private fun WeekLoading() { +private fun WeekLoading(dayCount: Int) { val scale = LocalTimelineZoom.current.scale val scrollState = rememberScrollState() + val headerHeight = weekDayHeaderHeight() Column(modifier = Modifier.fillMaxSize()) { // Header skeleton - Row(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + Row(modifier = Modifier.fillMaxWidth().padding(SKELETON_HEADER_PADDING)) { Spacer(Modifier.width(GUTTER_WIDTH)) - repeat(7) { + repeat(dayCount) { Box( modifier = Modifier .weight(1f) .padding(horizontal = 2.dp) - .height(36.dp) + .height(SKELETON_HEADER_HEIGHT) .background( MaterialTheme.colorScheme.surfaceContainer, RoundedCornerShape(8.dp), @@ -1038,7 +1563,9 @@ private fun WeekLoading() { BoxWithConstraints(modifier = Modifier.fillMaxSize()) { // Same scale resolution as the loaded timeline, so the skeleton's // columns don't resize the moment the real week arrives. - val hourHeight = scale.hourHeight(maxHeight) + val fitHeight = maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 - + headerHeight - TIMELINE_TOP_GAP + val hourHeight = scale.hourHeight(fitHeight) val totalHeight = hourHeight * 24 // The skeleton wears the loaded columns' own ground, so the arrival // of the real week doesn't flash solid blocks into a gapped grid. @@ -1053,7 +1580,7 @@ private fun WeekLoading() { .verticalScroll(scrollState), ) { Spacer(Modifier.width(GUTTER_WIDTH)) - repeat(7) { + repeat(dayCount) { Box( modifier = Modifier .weight(1f) @@ -1093,11 +1620,11 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri */ private fun formatWeekTitle( weekStart: LocalDate, + weekEnd: LocalDate, locale: Locale, currentYear: Int, abbreviated: Boolean = false, ): String { - val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY) return formatCalendarTitle( date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1), locale = locale, @@ -1106,3 +1633,17 @@ private fun formatWeekTitle( forceYear = weekEnd.year != weekStart.year, ) } + +/** The day's title: its weekday and date, abbreviated to the date alone when short of room. */ +private fun formatDayTitle( + date: LocalDate, + locale: Locale, + currentYear: Int, + abbreviated: Boolean = false, +): String = + formatCalendarTitle( + date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day), + locale = locale, + currentYear = currentYear, + skeleton = if (abbreviated) "dMMM" else "EEEdMMM", + ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 53c34eb6..e226fe67 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -9,18 +9,22 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.displayPriority import de.jeanlucmakiola.calendula.domain.FailureReason 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.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow 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.distinctUntilChanged +import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -30,6 +34,7 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atTime +import kotlinx.datetime.daysUntil import kotlinx.datetime.minus import kotlinx.datetime.plus import kotlinx.datetime.toInstant @@ -38,19 +43,42 @@ import java.util.Locale import kotlin.time.Clock import kotlin.time.Instant import javax.inject.Inject +import kotlin.math.abs 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) -@HiltViewModel -class WeekViewModel @Inject constructor( +abstract class PagedTimelineViewModel( private val repository: CalendarRepository, settingsPrefs: SettingsPrefs, - @IoDispatcher private val io: CoroutineDispatcher, + private val io: CoroutineDispatcher, + gridRule: Flow<(LocalDate) -> PageGrid>, + initialRule: (LocalDate) -> PageGrid, ) : ViewModel() { private val zone = TimeZone.currentSystemDefault() - private val locale: Locale = Locale.getDefault() /** Whether to fade events that have already finished (display concern only). */ val dimCompletedEvents: StateFlow = settingsPrefs.dimCompletedEvents @@ -63,76 +91,91 @@ class WeekViewModel @Inject constructor( private val todayDate: LocalDate get() = Clock.System.now().toLocalDateTime(zone).date - /** First day of the week, from the Settings preference (AUTO → locale). */ - private val weekStart: StateFlow = settingsPrefs.weekStart - .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. + // A day on the visible page; the page itself comes from the grid, so a + // settings change re-frames the same days instead of jumping. private val _anchor = MutableStateFlow(todayDate) + val anchor: StateFlow = _anchor - val weekStartDate: StateFlow = - 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 val position: StateFlow = + combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) } + .distinctUntilChanged() .stateIn( scope = viewModelScope, 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 = - combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) } + position.map { DaySpan(it.grid.startForPage(it.page), it.grid.dayCount) } .distinctUntilChanged() - .flatMapLatest { start -> - val range = weekRange(start, zone) - combine( - repository.calendars(), - repository.instances(range), - ) { calendars, instances -> - buildState(start, calendars, instances) - } - } + .flatMapLatest { span -> span(span).filterNot { it is WeekUiState.Loading } } .catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) } - .flowOn(io) .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), initialValue = WeekUiState.Loading, ) - fun goToPrev() { - _anchor.value = _anchor.value.minus(7, DateTimeUnit.DAY) + private val spans = PageStateCache( + 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)), + ) { calendars, instances -> + buildState(span, calendars, instances) + } + .catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) } + .flowOn(io) } - fun goToNext() { - _anchor.value = _anchor.value.plus(7, DateTimeUnit.DAY) + /** The days of one page, shared between that page and [state]. Main thread only. */ + fun span(span: DaySpan): StateFlow = 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() { - _anchor.value = todayDate - } - - /** Jump to the week containing [date] (drawer jump-to-date). */ - fun goToDate(date: LocalDate) { + /** Move the anchor to [date], re-framing the pages if the grid depends on it. */ + fun jumpTo(date: LocalDate) { _anchor.value = date } + fun goToToday() = jumpTo(todayDate) + fun goToDate(date: LocalDate) = jumpTo(date) + private fun buildState( - start: LocalDate, + span: DaySpan, calendars: List, instances: List, ): WeekUiState { 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 timed = instances.filterNot { it.isAllDay } return WeekUiState.Success( - weekStart = start, + weekStart = span.start, today = todayDate, days = days, allDaySpans = layoutAllDay(allDay, days, zone), @@ -141,6 +184,58 @@ class WeekViewModel @Inject constructor( } } +/** Whole weeks, starting on the first day from Settings (AUTO → locale). */ +@HiltViewModel +class WeekViewModel @Inject constructor( + repository: CalendarRepository, + settingsPrefs: SettingsPrefs, + @IoDispatcher io: CoroutineDispatcher, +) : PagedTimelineViewModel( + repository = repository, + settingsPrefs = settingsPrefs, + 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, + @IoDispatcher io: CoroutineDispatcher, +) : PagedTimelineViewModel( + repository = repository, + settingsPrefs = settingsPrefs, + 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, + @IoDispatcher io: CoroutineDispatcher, +) : PagedTimelineViewModel( + repository = repository, + settingsPrefs = settingsPrefs, + io = io, + gridRule = flowOf { _: LocalDate -> DayGrid }, + initialRule = { DayGrid }, +) + /** * 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; @@ -160,7 +255,13 @@ internal fun layoutAllDay( } // Declined bars are packed after every other one, so on any day they // cover they land in a lane below it (#230). - .sortedWith(compareBy({ it.event.isDeclined }, { it.startCol }, { it.endCol })) + .sortedWith( + compareBy { 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. // A single "last occupied column" only answers correctly while spans arrive @@ -194,10 +295,10 @@ internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate { return minus(offset, DateTimeUnit.DAY) } -/** Half-open instant range covering the seven days starting at [start]. */ -internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange { +/** Instant range covering the [count] days starting at [start]. */ +internal fun spanRange(start: LocalDate, count: Int, zone: TimeZone): ClosedRange { 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 } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt index 977ac1fc..d336cdf3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt @@ -32,10 +32,13 @@ internal fun WeekViewPreview( weekStart: DayOfWeek, height: Dp, modifier: Modifier = Modifier, + dayCount: Int = 7, ) { val zone = remember { TimeZone.currentSystemDefault() } 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() LaunchedEffect(Unit) { snapshotFlow { scrollState.maxValue }.first { it > 0 } @@ -46,6 +49,8 @@ internal fun WeekViewPreview( ScaledViewPreview(height = height, modifier = modifier) { WeekSuccess( state = state, + showWeekNumber = dayCount == 7, + headerBias = if (dayCount == 1) TimelineKind.Day.headerBias else 0f, scrollState = scrollState, allDayHeight = state.allDayStripHeight(), dragController = rememberTimelineDragController(), @@ -61,10 +66,11 @@ internal fun WeekViewPreview( private fun sampleWeekState( today: LocalDate, weekStart: DayOfWeek, + dayCount: Int, zone: TimeZone, ): WeekUiState.Success { - val start = today.startOfWeek(weekStart) - val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) } + val start = if (dayCount == 7) today.startOfWeek(weekStart) else today + val days = (0 until dayCount).map { start.plus(it, DateTimeUnit.DAY) } val events = sampleTimelineEvents(days, today, zone) return WeekUiState.Success( weekStart = start, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8ef8b993..cf9413d0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,10 +1,10 @@ Calendula A modern calendar. - - Calendar + + Calendar Loading… @@ -54,7 +54,9 @@ Previous month Next month + Open week %1$d Today + Show full month agenda More actions Open menu Settings @@ -67,6 +69,10 @@ Today + + Earlier events + Later events + Back Edit @@ -349,12 +355,18 @@ Snooze Dismiss + Today Tomorrow Yesterday Month Week + + + %d day + %d days + Day Agenda View @@ -470,7 +482,10 @@ each hour") describes a feature that no longer exists and needs redoing. --> Hour grid Seat each hour in its own cell in week and day view + Multi-day view + How many days the multi-day view shows side by side. Today opens in the first column. Hour height + Set by Visible hours How much vertical space one hour takes in week and day view. Both views share this setting. You can also pinch the timeline with two fingers to set any height in between. Fit whole day All 24 hours on one screen, no scrolling @@ -482,6 +497,13 @@ Roomier blocks, more scrolling Custom The height you pinched the timeline to + Visible hours + Fit a range of hours to the screen when week, multi-day or day view opens + %1$s – %2$s fills the screen when a view opens + From + Until + The hour at the top of the screen when week, multi-day or day view opens. Earlier hours are still a scroll away. + The hour at the bottom of the screen when a view opens. Later hours are still a scroll away, and pinching zooms until the view is opened again. Dim completed events Fade events that have already ended in month and week view Drag to reschedule @@ -530,7 +552,7 @@ Month view Month view style Pages - One month fills the screen. Swipe left or right to change month. + One month fills the screen. Swipe left or right to change month, or swipe up to show the month agenda. Scrolling months Each month sits under its own heading, with a little space setting it apart from the next. Seamless weeks diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 8eeef6ac..f09fe039 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -18,6 +18,7 @@ + diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index c369cf61..db59c958 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -1075,16 +1075,29 @@ class EventWriteMapperTest { } @Test - fun `occurrence exception carries the palette key`() { + fun `occurrence exception leaves the palette key out`() { + // The provider crashes on an exception insert carrying a colour key + // (Codeberg #338); the data source sets it with an update afterwards. val values = buildOccurrenceExceptionValues( form = form().copy(colorKey = "2", color = 0xFFE67C00.toInt()), originalInstanceMillis = 0L, zone = berlin, ) - assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, "2") + assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY) assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR) } + @Test + fun `occurrence exception with a custom colour writes it directly`() { + val values = buildOccurrenceExceptionValues( + form = form().copy(color = 0xFFE67C00.toInt()), + originalInstanceMillis = 0L, + zone = berlin, + ) + assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, 0xFFE67C00.toInt()) + assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null) + } + @Test fun `occurrence exception with no colour clears both columns`() { val values = buildOccurrenceExceptionValues( diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index e4b28f08..42fc64f6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import kotlinx.coroutines.flow.first @@ -125,6 +126,24 @@ class SettingsPrefsTest { assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.Comfortable) } + @Test + fun `visible hours default off and round-trip`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.timelineHours.first()).isEqualTo(TimelineHours(enabled = false, 7, 22)) + prefs.setTimelineHours(TimelineHours(enabled = true, 6, 20)) + assertThat(prefs.timelineHours.first()).isEqualTo(TimelineHours(enabled = true, 6, 20)) + // Switching off keeps the chosen hours for next time. + prefs.setTimelineHours(TimelineHours(enabled = false, 6, 20)) + assertThat(prefs.timelineHours.first()).isEqualTo(TimelineHours(enabled = false, 6, 20)) + } + + @Test + fun `visible hours are stored valid`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + prefs.setTimelineHours(TimelineHours(enabled = true, 23, 22)) + assertThat(prefs.timelineHours.first()).isEqualTo(TimelineHours(enabled = true, 20, 22)) + } + @Test fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(newDataStore(tempDir)) @@ -235,7 +254,8 @@ class SettingsPrefsTest { prefs.setQuickSwitchConfig(config) val loaded = prefs.quickSwitchConfig.first() assertThat(loaded.order).containsExactly( - CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day, + CalendarView.Agenda, CalendarView.Month, CalendarView.Week, + CalendarView.MultiDay, CalendarView.Day, ).inOrder() assertThat(loaded.enabled).containsExactly(CalendarView.Agenda, CalendarView.Month) assertThat(loaded.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder() @@ -252,13 +272,14 @@ class SettingsPrefsTest { m } val config = prefs.quickSwitchConfig.first() - // Stored order first (Day, Week), then the omitted views in default order. + // Stored order first, with the new view after Week. assertThat(config.order).containsExactly( - CalendarView.Day, CalendarView.Week, CalendarView.Month, CalendarView.Agenda, + CalendarView.Day, CalendarView.Week, CalendarView.MultiDay, + CalendarView.Month, CalendarView.Agenda, ).inOrder() - // Day was explicitly disabled; the appended Month & Agenda default enabled. + // Day was explicitly disabled; the missing views default enabled. assertThat(config.enabled).containsExactly( - CalendarView.Week, CalendarView.Month, CalendarView.Agenda, + CalendarView.Week, CalendarView.MultiDay, CalendarView.Month, CalendarView.Agenda, ) } @@ -272,9 +293,12 @@ class SettingsPrefsTest { val newOrder = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day) prefs.updateQuickSwitch { it.copy(order = newOrder) } val config = prefs.quickSwitchConfig.first() - assertThat(config.order).containsExactlyElementsIn(newOrder).inOrder() - assertThat(config.enabled).containsExactly( + assertThat(config.order).containsExactly( CalendarView.Agenda, CalendarView.Month, CalendarView.Week, + CalendarView.MultiDay, CalendarView.Day, + ).inOrder() + assertThat(config.enabled).containsExactly( + CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.MultiDay, ) } @@ -286,7 +310,8 @@ class SettingsPrefsTest { listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month), ) assertThat(prefs.drawerViewOrder.first()).containsExactly( - CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month, + CalendarView.Agenda, CalendarView.Day, CalendarView.Week, + CalendarView.MultiDay, CalendarView.Month, ).inOrder() } @@ -300,7 +325,8 @@ class SettingsPrefsTest { m } assertThat(prefs.drawerViewOrder.first()).containsExactly( - CalendarView.Agenda, CalendarView.Week, CalendarView.Month, CalendarView.Day, + CalendarView.Agenda, CalendarView.Week, CalendarView.MultiDay, + CalendarView.Month, CalendarView.Day, ).inOrder() } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt index c8014a1b..13d20b6c 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt @@ -38,6 +38,7 @@ class ReminderTimeTextTest { is24Hour = is24Hour, today = today ?: java.time.Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate(), firstDayOfWeek = firstDayOfWeek, + todayLabel = "Today", tomorrowLabel = "Tomorrow", yesterdayLabel = "Yesterday", ) @@ -130,38 +131,74 @@ class ReminderTimeTextTest { } @Test - fun `all-day single day shows one date, read in UTC`() { + fun `all-day single day today reads as today, read in UTC`() { val text = text( beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)), endMillis = utcMidnight(LocalDate.of(2026, 6, 12)), isAllDay = true, // Zone must not matter for all-day events: UTC midnight is - // 02:00 in Berlin — naive local reading would shift the day. + // the previous evening in Los Angeles — naive local reading would shift the day. + zone = ZoneId.of("America/Los_Angeles"), today = LocalDate.of(2026, 6, 11), ) - assertThat(text).isEqualTo("11.06.2026") + assertThat(text).isEqualTo("Today") } @Test - fun `all-day event tomorrow still shows the exact date, no relative prefix`() { + fun `all-day event tomorrow reads as tomorrow`() { val text = text( beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)), endMillis = utcMidnight(LocalDate.of(2026, 6, 13)), isAllDay = true, today = LocalDate.of(2026, 6, 11), ) - assertThat(text).isEqualTo("12.06.2026") + assertThat(text).isEqualTo("Tomorrow") } @Test - fun `all-day multi-day shows the last covered day, not the exclusive end`() { + fun `all-day event later this week reads as the short weekday`() { + // 2026-06-11 is a Thursday; Saturday the 13th shares its week. val text = text( - beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)), - endMillis = utcMidnight(LocalDate.of(2026, 6, 13)), + beginMillis = utcMidnight(LocalDate.of(2026, 6, 13)), + endMillis = utcMidnight(LocalDate.of(2026, 6, 14)), + isAllDay = true, + locale = Locale.US, + today = LocalDate.of(2026, 6, 11), + ) + assertThat(text).isEqualTo("Sat") + } + + @Test + fun `all-day event next week falls back to the exact date`() { + val text = text( + beginMillis = utcMidnight(LocalDate.of(2026, 6, 16)), + endMillis = utcMidnight(LocalDate.of(2026, 6, 17)), isAllDay = true, today = LocalDate.of(2026, 6, 11), ) - assertThat(text).isEqualTo("11.06.2026 – 12.06.2026") + assertThat(text).isEqualTo("16.06.2026") + } + + @Test + fun `all-day multi-day reads relative start and the last covered day`() { + val text = text( + beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)), + endMillis = utcMidnight(LocalDate.of(2026, 6, 14)), + isAllDay = true, + today = LocalDate.of(2026, 6, 11), + ) + assertThat(text).isEqualTo("Tomorrow – 13.06.2026") + } + + @Test + fun `all-day multi-day further out keeps both dates`() { + val text = text( + beginMillis = utcMidnight(LocalDate.of(2026, 7, 1)), + endMillis = utcMidnight(LocalDate.of(2026, 7, 3)), + isAllDay = true, + today = LocalDate.of(2026, 6, 11), + ) + assertThat(text).isEqualTo("01.07.2026 – 02.07.2026") } @Test @@ -173,6 +210,6 @@ class ReminderTimeTextTest { isAllDay = true, today = LocalDate.of(2026, 6, 11), ) - assertThat(text).isEqualTo("11.06.2026") + assertThat(text).isEqualTo("Today") } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt index caed3b22..0446a90f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt @@ -156,4 +156,5 @@ class EventToneTest { assertThat(eventTone(0xFFFFF6C0.toInt(), dark = false, harmonise = false).onContainer) .isEqualTo(0xFF000000.toInt()) } + } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaItemDatesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaItemDatesTest.kt new file mode 100644 index 00000000..4e5c7b1b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaItemDatesTest.kt @@ -0,0 +1,40 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.LocalDate +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +/** The day each agenda list item belongs to, for the focus it hands back (#184). */ +class AgendaItemDatesTest { + + private val monday = LocalDate(2026, 9, 28) + private val tuesday = LocalDate(2026, 9, 29) + + private fun event(id: Long) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "Event", + start = Instant.fromEpochMilliseconds(0), + end = Instant.fromEpochMilliseconds(0), + isAllDay = false, + color = 0xFF000000.toInt(), + location = null, + ) + + @Test + fun `each day counts its header, events and gap`() { + val days = listOf(AgendaDay(monday, listOf(event(1), event(2))), AgendaDay(tuesday, listOf(event(3)))) + assertThat(agendaItemDates(days)).containsExactly( + monday, monday, monday, monday, + tuesday, tuesday, tuesday, + ).inOrder() + } + + @Test + fun `an empty day counts its placeholder`() { + assertThat(agendaItemDates(listOf(AgendaDay(monday, emptyList())))).hasSize(3) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt index 37d01d5f..aee5190b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt @@ -77,6 +77,16 @@ class GroupAgendaDaysTest { .containsExactly(LocalDate(2026, 7, 2)) } + @Test + fun `multi-day events precede single all-day and timed events`() { + val multi = event(1, "Multi", at(2026, 7, 1), at(2026, 7, 4), isAllDay = true) + val single = event(2, "Single", at(2026, 7, 2), at(2026, 7, 3), isAllDay = true) + val timed = event(3, "Timed", at(2026, 7, 2, 8), at(2026, 7, 2, 9)) + val second = days(listOf(timed, single, multi)).first { it.date == LocalDate(2026, 7, 2) } + assertThat(second.events.map { it.title }) + .containsExactly("Multi", "Single", "Timed").inOrder() + } + @Test fun `an event ending exactly at midnight does not reach the next day`() { val e = event(1, "late", at(2026, 7, 1, 22, 0), at(2026, 7, 2, 0, 0)) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/BlockTimeRangeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/BlockTimeRangeTest.kt new file mode 100644 index 00000000..883c4671 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/BlockTimeRangeTest.kt @@ -0,0 +1,48 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.ui.unit.dp +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class BlockTimeRangeTest { + + /** Fits whatever is at most [chars] characters wide. */ + private fun upTo(chars: Int): (String) -> Boolean = { it.length <= chars } + + @Test + fun `a range that fits stays on one line`() { + assertThat(blockTimeRange("6:00 PM", "7:00 PM", upTo(20))) + .isEqualTo("6:00\u00A0PM–7:00\u00A0PM") + } + + @Test + fun `a narrow column hangs the dash off the start time`() { + assertThat(blockTimeRange("6:00 PM", "7:00 PM", upTo(8))) + .isEqualTo("6:00\u00A0PM–\n7:00\u00A0PM") + } + + @Test + fun `the dash leads the end time when only that side has room`() { + assertThat(blockTimeRange("10:00 PM", "7:00 PM", upTo(8))) + .isEqualTo("10:00\u00A0PM\n–7:00\u00A0PM") + } + + @Test + fun `with room for neither dash the times just stack`() { + assertThat(blockTimeRange("6:00 PM", "7:00 PM", upTo(7))) + .isEqualTo("6:00\u00A0PM\n7:00\u00A0PM") + } + + @Test + fun `a time too wide for the column keeps its space to wrap at`() { + assertThat(blockTimeRange("6:00 PM", "7:00 PM", upTo(5))) + .isEqualTo("6:00 PM–\u200B7:00 PM") + } + + @Test + fun `the label takes every whole line the spare height holds`() { + assertThat(timeLinesWithin(spare = 0.dp, timeLine = 13.dp)).isEqualTo(1) + assertThat(timeLinesWithin(spare = 13.dp, timeLine = 13.dp)).isEqualTo(2) + assertThat(timeLinesWithin(spare = 40.dp, timeLine = 13.dp)).isEqualTo(4) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt new file mode 100644 index 00000000..6268b26f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt @@ -0,0 +1,147 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.week.TimedBlock +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +/** Which events a column's edge bars point at, and where a tap scrolls (#188). */ +class OutOfViewEdgesTest { + + private fun block(id: Long, startMin: Int, endMin: Int) = TimedBlock( + event = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "Event $id", + start = Instant.fromEpochMilliseconds(0), + end = Instant.fromEpochMilliseconds(0), + isAllDay = false, + color = 0xFF112233.toInt(), + location = null, + ), + startMin = startMin, + endMin = endMin, + lane = 0, + laneCount = 1, + ) + + private val early = block(1, 7 * 60, 8 * 60) + private val breakfast = block(2, 8 * 60, 9 * 60) + private val lunch = block(3, 12 * 60, 13 * 60) + private val dinner = block(4, 19 * 60, 20 * 60) + private val late = block(5, 21 * 60, 22 * 60) + + // Viewport 10:00–17:00. + private fun outOf(vararg blocks: TimedBlock) = + outOfViewEvents(blocks.toList(), topMin = 600f, bottomMin = 1020f) + + @Test + fun `the nearest hidden event on each side is picked`() { + val events = outOf(early, breakfast, lunch, dinner, late) + assertThat(events.above).isEqualTo(breakfast) + assertThat(events.below).isEqualTo(dinner) + } + + @Test + fun `an event in view points nowhere`() { + assertThat(outOf(lunch)).isEqualTo(OutOfViewEvents.None) + } + + @Test + fun `an event cut by the edge is not out of view`() { + val straddling = block(6, 9 * 60, 11 * 60) + assertThat(outOf(straddling).above).isNull() + } + + @Test + fun `an event disappears from the edge as soon as a sliver enters view`() { + val sliver = block(6, 9 * 60, 10 * 60 + 5) + assertThat(outOf(sliver).above).isNull() + val tip = block(7, 17 * 60 - 5, 18 * 60) + assertThat(outOf(tip).below).isNull() + val justAbove = block(8, 9 * 60, 10 * 60) + val justBelow = block(9, 17 * 60, 18 * 60) + assertThat(outOf(justAbove).above).isEqualTo(justAbove) + assertThat(outOf(justBelow).below).isEqualTo(justBelow) + } + + @Test + fun `the bar fades before its event reaches the viewport`() { + assertThat(edgeBarAlpha(gapPx = 24f, fadeLeadPx = 24f)).isEqualTo(1f) + assertThat(edgeBarAlpha(gapPx = 12f, fadeLeadPx = 24f)).isEqualTo(0.5f) + assertThat(edgeBarAlpha(gapPx = 0f, fadeLeadPx = 24f)).isEqualTo(0f) + assertThat(edgeBarAlpha(gapPx = -1f, fadeLeadPx = 24f)).isEqualTo(0f) + } + + @Test + fun `a column scrolled to its top points at nothing above it`() { + val first = block(6, 0, 30) + val events = outOfViewEvents(listOf(first), topMin = 0f, bottomMin = 420f) + assertThat(events.above).isNull() + } + + @Test + fun `a short event is judged by the height it renders at`() { + // Five minutes ending at 09:58 still draws 26 minutes tall, past 10:00. + val blip = block(6, 9 * 60 + 53, 9 * 60 + 58) + assertThat(outOf(blip).above).isNull() + } + + @Test + fun `an event above scrolls to its start less the margin`() { + val target = revealScrollTarget(breakfast, ViewportEdge.Top, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700) + assertThat(target).isEqualTo(780) + } + + @Test + fun `an event below that fits lands whole at the bottom`() { + val target = revealScrollTarget(dinner, ViewportEdge.Bottom, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700) + // Its end, 2000, plus the margin, sits at the viewport's bottom. + assertThat(target).isEqualTo(1320) + } + + @Test + fun `an event below taller than the viewport lands with its start at the top`() { + val long = block(6, 12 * 60, 22 * 60) + val target = revealScrollTarget(long, ViewportEdge.Bottom, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700) + assertThat(target).isEqualTo(1180) + } + + @Test + fun `the target stays within the scroll range`() { + val first = block(6, 0, 30) + assertThat( + revealScrollTarget(first, ViewportEdge.Top, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700), + ).isEqualTo(0) + val last = block(7, 23 * 60, 24 * 60) + assertThat( + revealScrollTarget(last, ViewportEdge.Bottom, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700), + ).isEqualTo(1700) + } + + // Viewport 1000..1700 px with both bars showing, 40 px of reach. + private fun tapped(y: Float, events: OutOfViewEvents = OutOfViewEvents(breakfast, dinner)) = + tappedEdge(events, y, viewportTop = 1000f, viewportPx = 700, reachPx = 40f) + + @Test + fun `a tap near an edge with a bar hits that bar`() { + assertThat(tapped(1010f)).isEqualTo(ViewportEdge.Top) + assertThat(tapped(1040f)).isEqualTo(ViewportEdge.Top) + assertThat(tapped(1690f)).isEqualTo(ViewportEdge.Bottom) + } + + @Test + fun `a tap past the bar's reach is left to the column`() { + assertThat(tapped(1041f)).isNull() + assertThat(tapped(1350f)).isNull() + assertThat(tapped(1659f)).isNull() + } + + @Test + fun `a tap near an edge without a bar is left to the column`() { + assertThat(tapped(1010f, OutOfViewEvents(null, dinner))).isNull() + assertThat(tapped(1690f, OutOfViewEvents(breakfast, null))).isNull() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/PageStateCacheTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/PageStateCacheTest.kt new file mode 100644 index 00000000..1266a71e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/PageStateCacheTest.kt @@ -0,0 +1,53 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import kotlin.math.abs + +@OptIn(ExperimentalCoroutinesApi::class) +class PageStateCacheTest { + + @Test + fun `the same key shares one state`() = runTest { + val cache = PageStateCache(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { flow { emit(it) } } + assertThat(cache.get(5)).isSameInstanceAs(cache.get(5)) + } + + @Test + fun `a dropped entry stops loading`() = runTest(UnconfinedTestDispatcher()) { + var running = 0 + val cache = PageStateCache(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { key -> + flow { + running++ + try { + emit(key) + awaitCancellation() + } finally { + running-- + } + } + } + backgroundScope.launch { cache.get(0).collect {} } + assertThat(running).isEqualTo(1) + + // Far enough away that 0 is let go, even though it is still collected. + cache.get(10) + assertThat(running).isEqualTo(0) + assertThat(cache.size).isEqualTo(1) + } + + @Test + fun `nearby entries are kept`() = runTest { + val cache = PageStateCache(backgroundScope, 0, { a, b -> abs(a - b) <= 2 }) { flow { emit(it) } } + cache.get(4) + cache.get(5) + cache.get(6) + assertThat(cache.size).isEqualTo(3) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineHoursTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineHoursTest.kt new file mode 100644 index 00000000..f7c06109 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineHoursTest.kt @@ -0,0 +1,115 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.ui.unit.dp +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class TimelineHoursTest { + + private val viewport = 600.dp + + @Test + fun `the chosen range exactly fills the viewport`() { + // 07–22 is 15 hours: 600dp / 15 = 40dp an hour. + assertThat(TimelineScale.FitHours(7, 22).hourHeight(viewport)).isEqualTo(40.dp) + } + + @Test + fun `the whole day fits like FitDay`() { + assertThat(TimelineScale.FitHours(0, 24).hourHeight(viewport)) + .isEqualTo(TimelineScale.FitDay.hourHeight(viewport)) + } + + @Test + fun `a short range on a tall screen stops at the pinch ceiling`() { + assertThat(TimelineScale.FitHours(9, 11).hourHeight(2000.dp)).isEqualTo(MAX_PINCH_HOUR_HEIGHT) + } + + @Test + fun `ranges are held to the day and the minimum span`() { + assertThat(TimelineHours.of(true, -3, 30)).isEqualTo(TimelineHours(true, 0, 24)) + assertThat(TimelineHours.of(true, 23, 22)).isEqualTo(TimelineHours(true, 20, 22)) + assertThat(TimelineHours.of(true, 5, 1)).isEqualTo(TimelineHours(true, 0, 2)) + } + + @Test + fun `the pickers only offer valid pairings`() { + assertThat(TimelineHours.startOptions(22)).isEqualTo((0..20).toList()) + assertThat(TimelineHours.endOptions(7)).isEqualTo((9..24).toList()) + } + + @Test + fun `opening scrolls to the start hour once the layout is at the fitted height`() { + // 40px an hour over a 600px viewport: 960px of content, 360px of scroll. + assertThat(startHourScroll(maxValue = 360, viewportPx = 600, startHour = 7, hourPx = 40f)) + .isEqualTo(280) + } + + @Test + fun `a layout still at the previous scale is waited out`() { + // Still 56px an hour: 1344 - 600 = 744 of scroll. + assertThat(startHourScroll(maxValue = 744, viewportPx = 600, startHour = 7, hourPx = 40f)) + .isNull() + // Not laid out at all yet. + assertThat(startHourScroll(maxValue = Int.MAX_VALUE, viewportPx = 0, startHour = 7, hourPx = 40f)) + .isNull() + } + + @Test + fun `the whole day needs no scroll`() { + assertThat(startHourScroll(maxValue = 0, viewportPx = 600, startHour = 0, hourPx = 25f)) + .isEqualTo(0) + } + + @Test + fun `a fitted range round-trips through storage`() { + val scale = TimelineScale.FitHours(6, 20) + assertThat(parseTimelineScale(scale.storageValue())).isEqualTo(scale) + assertThat(parseTimelineScale("hours:x-2")).isEqualTo(TimelineScale.Regular) + } + + @Test + fun `opening with visible hours on replaces a pinched scale`() { + val persisted = mutableListOf() + val zoom = TimelineZoom(TimelineScale.custom(90.dp)) { persisted += it } + zoom.adoptHours(TimelineHours(true, 7, 22)) + + assertThat(zoom.open()).isEqualTo(7) + assertThat(zoom.scale).isEqualTo(TimelineScale.FitHours(7, 22)) + + // A pinch overrides it for the visit but is not saved over the preference. + zoom.beginPinch() + zoom.pinchTo(100.dp) + zoom.endPinch() + assertThat(zoom.scale).isEqualTo(TimelineScale.custom(100.dp)) + assertThat(persisted).isEmpty() + + // Opening again fits the range again. + zoom.open() + assertThat(zoom.scale).isEqualTo(TimelineScale.FitHours(7, 22)) + } + + @Test + fun `a late stored scale does not undo the fit, and switching off restores it`() { + val zoom = TimelineZoom(TimelineScale.Regular) {} + zoom.adoptHours(TimelineHours(true, 7, 22)) + zoom.open() + zoom.adopt(TimelineScale.Comfortable) + assertThat(zoom.scale).isEqualTo(TimelineScale.FitHours(7, 22)) + + zoom.adoptHours(TimelineHours(false, 7, 22)) + assertThat(zoom.open()).isNull() + assertThat(zoom.scale).isEqualTo(TimelineScale.Comfortable) + } + + @Test + fun `with visible hours off a pinch is still saved`() { + val persisted = mutableListOf() + val zoom = TimelineZoom(TimelineScale.Regular) { persisted += it } + assertThat(zoom.open()).isNull() + zoom.beginPinch() + zoom.pinchTo(70.dp) + zoom.endPinch() + assertThat(persisted).containsExactly(TimelineScale.custom(70.dp)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt index fb73dc54..aae8d28d 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt @@ -65,6 +65,14 @@ class ViewBackStackTest { assertThat(stack.drillToDay()).isEqualTo(stack) } + @Test + fun `week number drills into week and back returns to month`() { + val stack = listOf(CalendarView.Month).drillToWeek() + assertThat(stack).containsExactly(CalendarView.Month, CalendarView.Week).inOrder() + assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Month) + assertThat(stack.drillToWeek()).isEqualTo(stack) + } + @Test fun `agenda widget date tap backs out agenda then home`() { // openDate(source = Agenda): root in Agenda over the Week home, then drill. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewMorphKeyTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewMorphKeyTest.kt new file mode 100644 index 00000000..b0c5a298 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewMorphKeyTest.kt @@ -0,0 +1,50 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class ViewMorphKeyTest { + + private fun instance(instanceId: Long, eventId: Long = 7, startMillis: Long = 1_000L) = EventInstance( + instanceId = instanceId, + eventId = eventId, + calendarId = 1, + title = "Standup", + start = Instant.fromEpochMilliseconds(startMillis), + end = Instant.fromEpochMilliseconds(startMillis + 3_600_000L), + isAllDay = false, + color = 0, + location = null, + ) + + private val day = LocalDate(2026, 9, 25) + + @Test + fun `the same occurrence on the same day matches across views`() { + // Each view reads its own instances query, so the row id is no anchor. + assertThat(ViewMorphKey.event(instance(instanceId = 1), day)) + .isEqualTo(ViewMorphKey.event(instance(instanceId = 99), day)) + } + + @Test + fun `a multi-day event keys each day it is drawn from apart`() { + assertThat(ViewMorphKey.event(instance(1), day)) + .isNotEqualTo(ViewMorphKey.event(instance(1), LocalDate(2026, 9, 26))) + } + + @Test + fun `two occurrences of one series stay apart`() { + assertThat(ViewMorphKey.event(instance(1, startMillis = 1_000L), day)) + .isNotEqualTo(ViewMorphKey.event(instance(2, startMillis = 2_000L), day)) + } + + @Test + fun `only the settled page morphs`() { + assertThat(morphPageActive(page = 5, settledPage = 5)).isTrue() + assertThat(morphPageActive(page = 4, settledPage = 5)).isFalse() + assertThat(morphPageActive(page = 6, settledPage = 5)).isFalse() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewScreenTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewScreenTest.kt new file mode 100644 index 00000000..538c7da0 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewScreenTest.kt @@ -0,0 +1,20 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** Which screen draws each view: the timeline views share one, so switching among them resizes it (#184). */ +class ViewScreenTest { + + @Test + fun `the day, multi-day and week views share the timeline`() { + assertThat(listOf(CalendarView.Day, CalendarView.MultiDay, CalendarView.Week).map { it.screen }.toSet()) + .containsExactly(ViewScreen.Timeline) + } + + @Test + fun `month and agenda each have their own screen`() { + assertThat(CalendarView.Month.screen).isEqualTo(ViewScreen.Month) + assertThat(CalendarView.Agenda.screen).isEqualTo(ViewScreen.Agenda) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/day/DayPagingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/day/DayPagingTest.kt new file mode 100644 index 00000000..ac37f9b4 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/day/DayPagingTest.kt @@ -0,0 +1,22 @@ +package de.jeanlucmakiola.calendula.ui.day + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test + +class DayPagingTest { + + @Test + fun `a page and its day round-trip`() { + listOf(LocalDate(2026, 9, 27), LocalDate(1990, 5, 16), LocalDate(2100, 2, 28)).forEach { day -> + assertThat(dayForPage(dayPageFor(day))).isEqualTo(day) + } + } + + @Test + fun `adjacent pages are adjacent days across a month end`() { + val page = dayPageFor(LocalDate(2026, 2, 28)) + assertThat(dayForPage(page + 1)).isEqualTo(LocalDate(2026, 3, 1)) + assertThat(dayForPage(page - 1)).isEqualTo(LocalDate(2026, 2, 27)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt index cb3b8de7..8ee8926f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt @@ -340,4 +340,71 @@ class EventEditViewModelTest { assertThat(fake.movedEvents).isEmpty() job.cancel() } + + @Test + fun `setTitle caps a long paste at 255 chars`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) } + val vm = viewModel(tempDir, fake) + val job = activate(vm) + + vm.openNew(LocalDate(2030, 1, 15)) + advanceUntilIdle() + vm.setTitle("a".repeat(300)) + + assertThat(vm.state.value?.form?.title).hasLength(MAX_SINGLE_LINE_FIELD) + job.cancel() + } + + @Test + fun `setTitle refuses to grow past 255 chars once capped`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) } + val vm = viewModel(tempDir, fake) + val job = activate(vm) + + vm.openNew(LocalDate(2030, 1, 15)) + advanceUntilIdle() + vm.setTitle("a".repeat(255)) + vm.setTitle("a".repeat(256)) + + assertThat(vm.state.value?.form?.title).hasLength(255) + job.cancel() + } + + @Test + fun `setLocation joins a multi-line address then caps it`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) } + val vm = viewModel(tempDir, fake) + val job = activate(vm) + + vm.openNew(LocalDate(2030, 1, 15)) + advanceUntilIdle() + vm.setLocation("${"a".repeat(200)}\n${"b".repeat(200)}") + + val location = vm.state.value?.form?.location + assertThat(location).hasLength(MAX_SINGLE_LINE_FIELD) + assertThat(location).contains(", ") + job.cancel() + } + + @Test + fun `capLength lets an already-over-long value shrink`() { + val overLong = "a".repeat(300) + val shortened = overLong.dropLast(1) + + assertThat(capLength(shortened, overLong)).hasLength(299) + } + + @Test + fun `capLength never splits a surrogate pair at the cut point`() { + // U+1F600, a surrogate pair, sits right across the 255 boundary. + val new = "a".repeat(254) + "😀" + + assertThat(capLength(new, "")).isEqualTo("a".repeat(254)) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt index ef2c56b8..71e9df28 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt @@ -222,17 +222,17 @@ class ContinuousMonthIndexTest { } @Test - fun `the split selection follows the month, landing on today when it's there`() { + fun `the split agenda starts at today in the current month`() { val today = LocalDate(2026, 6, 10) - assertThat(selectionForMonth(jun26, today)).isEqualTo(today) + assertThat(agendaStartForMonth(jun26, today)).isEqualTo(today) } @Test - fun `the split selection falls to the 1st of any other month`() { + fun `the split agenda starts at the 1st of another month`() { val today = LocalDate(2026, 6, 10) - assertThat(selectionForMonth(YearMonth(2026, Month.JULY), today)) + assertThat(agendaStartForMonth(YearMonth(2026, Month.JULY), today)) .isEqualTo(LocalDate(2026, 7, 1)) - assertThat(selectionForMonth(YearMonth(2025, Month.JUNE), today)) + assertThat(agendaStartForMonth(YearMonth(2025, Month.JUNE), today)) .isEqualTo(LocalDate(2025, 6, 1)) } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt index 2cddeae4..a38c2588 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt @@ -163,6 +163,18 @@ class MonthLayoutTest { assertThat(week.spans.map { it.lane }).containsExactly(0, 1) } + @Test + fun `multi-day bar is above a one-day all-day bar on the same date`() { + val day = LocalDate(2026, 6, 10) + val single = allDay(day, id = 1L, title = "Single") + val multi = allDay(day, day.plus(2, DateTimeUnit.DAY), id = 2L, title = "Multi") + val week = layoutCalendarWeek(weekOf8th, listOf(single, multi), zone) + val lanes = week.spans.associate { it.event.title to it.lane } + assertThat(lanes.getValue("Multi")).isLessThan(lanes.getValue("Single")) + assertThat(instancesByDay(listOf(day), listOf(single, multi), zone).getValue(day).map { it.title }) + .containsExactly("Multi", "Single").inOrder() + } + @Test fun `single-day timed events stay pills and sort by start`() { val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 1L) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometryTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometryTest.kt new file mode 100644 index 00000000..9dd729c7 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometryTest.kt @@ -0,0 +1,107 @@ +package de.jeanlucmakiola.calendula.ui.week + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +/** The timeline's columns, standing still and mid view switch (#184). */ +class ColumnGeometryTest { + + // 2026-09-27 is a Sunday; the week runs Sun..Sat. + private val sunday = LocalDate(2026, 9, 27) + private val week = DaySpan(sunday, 7) + private val monday = sunday.plus(1, DateTimeUnit.DAY) + private val day = DaySpan(monday, 1) + private val width = 700f + private val gap = 7f + + @Test + fun `a page's columns follow the pitch`() { + val slot = week.slot(sunday.plus(3, DateTimeUnit.DAY), width, gap) + assertThat(slot.left).isEqualTo(3 * 101f) + assertThat(slot.width).isEqualTo(94f) + } + + @Test + fun `the ends of a switch are the two pages`() { + var p = 0f + val geometry = ColumnGeometry(week, day) { p } + assertThat(geometry.slot(monday, width, gap)).isEqualTo(week.slot(monday, width, gap)) + p = 1f + assertThat(geometry.slot(monday, width, gap)).isEqualTo(day.slot(monday, width, gap)) + } + + @Test + fun `days off the target page end off screen on their side`() { + val geometry = ColumnGeometry(week, day) { 1f } + assertThat(geometry.slot(sunday, width, gap).right).isAtMost(0f) + assertThat(geometry.slot(monday.plus(1, DateTimeUnit.DAY), width, gap).left).isAtLeast(width) + } + + @Test + fun `a switch covers both pages without a break`() { + // Focus on Saturday: the six-day run starting there reaches into next week. + val saturday = sunday.plus(6, DateTimeUnit.DAY) + val geometry = ColumnGeometry(week, DaySpan(saturday, 6)) { 0f } + assertThat(geometry.dates).hasSize(12) + assertThat(geometry.dates.first()).isEqualTo(sunday) + assertThat(geometry.dates.zipWithNext().all { (a, b) -> b == a.plus(1, DateTimeUnit.DAY) }).isTrue() + } + + @Test + fun `a bar spans its extent on each page`() { + val tuesday = monday.plus(1, DateTimeUnit.DAY) + val range = monday..tuesday + val geometry = ColumnGeometry(week, day) { 0f } + val extent = geometry.extent(range, monday..monday, width, 0f) + assertThat(extent.left).isEqualTo(week.slot(monday, width, 0f).left) + assertThat(extent.right).isEqualTo(week.slot(tuesday, width, 0f).right) + } + + @Test + fun `a bar only one page has keeps its days on the other`() { + val bar = AllDaySpan(event(1), startCol = 2, endCol = 3, lane = 1) + val from = state(week, listOf(bar)) + val to = state(day, emptyList()) + val placed = morphBars(from, to).single() + assertThat(placed.fromRange).isEqualTo(placed.toRange) + assertThat(placed.toLane).isEqualTo(1) + } + + @Test + fun `a bar on both pages is placed once, clipped to each`() { + val event = event(1) + val from = state(week, listOf(AllDaySpan(event, startCol = 0, endCol = 6, lane = 0))) + val to = state(day, listOf(AllDaySpan(event, startCol = 0, endCol = 0, lane = 0))) + val placed = morphBars(from, to).single() + assertThat(placed.fromRange).isEqualTo(sunday..sunday.plus(6, DateTimeUnit.DAY)) + assertThat(placed.toRange).isEqualTo(monday..monday) + } + + private fun state(span: DaySpan, bars: List): WeekUiState.Success { + val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) } + return WeekUiState.Success( + weekStart = span.start, + today = sunday, + days = days, + allDaySpans = bars, + timedByDay = emptyMap(), + ) + } + + private fun event(id: Long) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "Event", + start = Instant.fromEpochMilliseconds(0), + end = Instant.fromEpochMilliseconds(0), + isAllDay = true, + color = 0xFF000000.toInt(), + location = null, + ) +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt new file mode 100644 index 00000000..96e5025b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt @@ -0,0 +1,40 @@ +package de.jeanlucmakiola.calendula.ui.week + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test + +/** Where the focused date goes as a timeline pages (#184). */ +class FocusOnPageTest { + + // 2026-09-30 is a Wednesday. + private val wednesday = LocalDate(2026, 9, 30) + + @Test + fun `a page that still shows the focus keeps it`() { + val grid = WeekGrid(DayOfWeek.MONDAY) + assertThat(grid.focusOnPage(wednesday, grid.pageFor(wednesday))).isEqualTo(wednesday) + } + + @Test + fun `a week on keeps the weekday`() { + val grid = WeekGrid(DayOfWeek.MONDAY) + val page = grid.pageFor(wednesday) + assertThat(grid.focusOnPage(wednesday, page + 1)).isEqualTo(LocalDate(2026, 10, 7)) + assertThat(grid.focusOnPage(wednesday, page - 2)).isEqualTo(LocalDate(2026, 9, 16)) + } + + @Test + fun `a run of days keeps the focus's column`() { + val grid = DaysGrid.startingAt(LocalDate(2026, 9, 29), 3) + val page = grid.pageFor(wednesday) + assertThat(grid.focusOnPage(wednesday, page + 1)).isEqualTo(LocalDate(2026, 10, 3)) + } + + @Test + fun `a day page is the focus`() { + val page = DayGrid.pageFor(wednesday) + assertThat(DayGrid.focusOnPage(wednesday, page + 5)).isEqualTo(LocalDate(2026, 10, 5)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt index 32c6c37d..3c30d2dd 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt @@ -49,8 +49,8 @@ class WeekLayoutTest { } @Test - fun `weekRange spans seven days`() { - val range = weekRange(mon, zone) + fun `spanRange spans seven days`() { + val range = spanRange(mon, 7, zone) assertThat(range.start).isEqualTo(at(mon, 0, 0)) // endInclusive is the last second of day 7 (Sunday 2026-06-14) assertThat(range.endInclusive).isEqualTo(LocalDate(2026, 6, 14).atTime(23, 59, 59).toInstant(zone)) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt new file mode 100644 index 00000000..0480c9f7 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt @@ -0,0 +1,81 @@ +package de.jeanlucmakiola.calendula.ui.week + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus +import org.junit.jupiter.api.Test + +class WeekPagingTest { + + // 2026-09-27 is a Sunday. + private val sunday = LocalDate(2026, 9, 27) + + @Test + fun `a page and its week round-trip for every first day`() { + DayOfWeek.entries.forEach { firstDay -> + val page = weekPageFor(sunday, firstDay) + val start = weekStartForPage(page, firstDay) + assertThat(start).isEqualTo(sunday.startOfWeek(firstDay)) + assertThat(weekPageFor(start, firstDay)).isEqualTo(page) + } + } + + @Test + fun `every day of a week maps to the same page`() { + val start = sunday.startOfWeek(DayOfWeek.MONDAY) + val pages = (0..6).map { weekPageFor(start.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) } + assertThat(pages.toSet()).hasSize(1) + } + + @Test + fun `adjacent pages are adjacent weeks`() { + val page = weekPageFor(sunday, DayOfWeek.SUNDAY) + assertThat(weekStartForPage(page + 1, DayOfWeek.SUNDAY)) + .isEqualTo(LocalDate(2026, 10, 4)) + assertThat(weekStartForPage(page - 1, DayOfWeek.SUNDAY)) + .isEqualTo(LocalDate(2026, 9, 20)) + } + + @Test + fun `dates before the epoch page resolve too`() { + val old = LocalDate(1990, 5, 16) + val page = weekPageFor(old, DayOfWeek.MONDAY) + assertThat(weekStartForPage(page, DayOfWeek.MONDAY)).isEqualTo(LocalDate(1990, 5, 14)) + } + + @Test + fun `multi-day pages begin on the selected date and advance by the chosen count`() { + (3..6).forEach { count -> + val grid = DaysGrid.startingAt(sunday, count) + val page = grid.pageFor(sunday) + assertThat(grid.startForPage(page)).isEqualTo(sunday) + assertThat(grid.startForPage(page + 1)).isEqualTo(sunday.plus(count, DateTimeUnit.DAY)) + assertThat(grid.pageFor(sunday.plus(count - 1, DateTimeUnit.DAY))).isEqualTo(page) + } + } + + @Test + fun `multi-day pages also work before the epoch`() { + val date = LocalDate(1990, 5, 16) + val grid = DaysGrid.startingAt(date, 3) + val page = grid.pageFor(date) + assertThat(grid.startForPage(page)).isEqualTo(date) + assertThat(grid.startForPage(page - 1)).isEqualTo(LocalDate(1990, 5, 13)) + } + + @Test + fun `a day page and its day round-trip`() { + listOf(LocalDate(2026, 9, 27), LocalDate(1990, 5, 16), LocalDate(2100, 2, 28)).forEach { day -> + assertThat(DayGrid.startForPage(DayGrid.pageFor(day))).isEqualTo(day) + } + } + + @Test + fun `adjacent day pages are adjacent days across a month end`() { + val page = DayGrid.pageFor(LocalDate(2026, 2, 28)) + assertThat(DayGrid.startForPage(page + 1)).isEqualTo(LocalDate(2026, 3, 1)) + assertThat(DayGrid.startForPage(page - 1)).isEqualTo(LocalDate(2026, 2, 27)) + } +} diff --git a/design/store/raw-tablet/01-week.png b/design/store/raw-tablet/01-week.png new file mode 100644 index 00000000..37c838f2 Binary files /dev/null and b/design/store/raw-tablet/01-week.png differ diff --git a/design/store/raw-tablet/02-month.png b/design/store/raw-tablet/02-month.png new file mode 100644 index 00000000..2146ba79 Binary files /dev/null and b/design/store/raw-tablet/02-month.png differ diff --git a/design/store/raw-tablet/03-agenda.png b/design/store/raw-tablet/03-agenda.png new file mode 100644 index 00000000..fb778150 Binary files /dev/null and b/design/store/raw-tablet/03-agenda.png differ diff --git a/design/store/raw-tablet/04-detail.png b/design/store/raw-tablet/04-detail.png new file mode 100644 index 00000000..e3faa62a Binary files /dev/null and b/design/store/raw-tablet/04-detail.png differ diff --git a/design/store/raw-tablet/05-recurring.png b/design/store/raw-tablet/05-recurring.png new file mode 100644 index 00000000..4bed67de Binary files /dev/null and b/design/store/raw-tablet/05-recurring.png differ diff --git a/design/store/raw-tablet/06-calendars.png b/design/store/raw-tablet/06-calendars.png new file mode 100644 index 00000000..01e4c0ed Binary files /dev/null and b/design/store/raw-tablet/06-calendars.png differ diff --git a/design/store/raw-tablet/07-dark.png b/design/store/raw-tablet/07-dark.png new file mode 100644 index 00000000..6c4829ef Binary files /dev/null and b/design/store/raw-tablet/07-dark.png differ diff --git a/design/store/raw/01-week.png b/design/store/raw/01-week.png new file mode 100644 index 00000000..919bae6d Binary files /dev/null and b/design/store/raw/01-week.png differ diff --git a/design/store/raw/02-month.png b/design/store/raw/02-month.png new file mode 100644 index 00000000..8c37d18f Binary files /dev/null and b/design/store/raw/02-month.png differ diff --git a/design/store/raw/03-agenda.png b/design/store/raw/03-agenda.png new file mode 100644 index 00000000..bd3048bf Binary files /dev/null and b/design/store/raw/03-agenda.png differ diff --git a/design/store/raw/04-detail.png b/design/store/raw/04-detail.png new file mode 100644 index 00000000..c64ac118 Binary files /dev/null and b/design/store/raw/04-detail.png differ diff --git a/design/store/raw/05-recurring.png b/design/store/raw/05-recurring.png new file mode 100644 index 00000000..df9c4d44 Binary files /dev/null and b/design/store/raw/05-recurring.png differ diff --git a/design/store/raw/06-calendars.png b/design/store/raw/06-calendars.png new file mode 100644 index 00000000..a05393a4 Binary files /dev/null and b/design/store/raw/06-calendars.png differ diff --git a/design/store/raw/07-dark.png b/design/store/raw/07-dark.png new file mode 100644 index 00000000..fa6277e6 Binary files /dev/null and b/design/store/raw/07-dark.png differ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ed3afdee..f444241a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,7 +27,7 @@ the package list (recurring writes, save conflicts, reminder delivery). ```mermaid flowchart TD subgraph UI ["ui/ — Compose screens + ViewModels"] - Screens["Month / Week / Day\nDetail / Edit / Settings\nOnboarding wizard"] + Screens["Month / Timeline / Agenda\nDetail / Edit / Settings\nOnboarding wizard"] end subgraph Data ["data/"] Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"] @@ -79,10 +79,42 @@ flowchart TD There is no navigation library. `MainActivity` hosts `RootScreen`, which gates on the first-launch wizard (`ui/onboarding/`), then shows -`CalendarHost`. `CalendarHost` holds the active view (month/week/day) -plus overlay state for detail, edit, and settings — full-screen overlays +`CalendarHost`. `CalendarHost` holds the active view (month, day/multi-day/week, +agenda) plus overlay state for detail, edit, and settings — full-screen overlays driven by `AnimatedVisibility` with a *held-key* pattern: the last shown key stays alive through the slide-out so content never flashes empty. +Overlays leave through floret-kit's `predictiveBackExit`, so a committed back +gesture finishes from its scaled preview. + +### Switching views (#184) + +- **One focused date.** `CalendarHost` owns a `ViewFocus` (`LocalViewFocus`) + that every view opens on and carries along. A view *reads* it once, on + entry, synchronously (`EnterOnFocus` before it reads its own position, so + its first frame is already on that date), and *writes* it only when the user + moves — a pager settling (`focusOnPage` keeps the date's place in the page), + a jump, Today, a tapped day, the agenda's top row. Writing on entry would + drift the date on every round trip. +- **Three screens, not five.** The host's `AnimatedContent` is keyed on + `ViewScreen` (month, timeline, agenda), not on the view: day, multi-day and + week are one `TimelineScreen` with a `TimelineKind` each and a view model + each. Keying the three on one `contentKey` instead would still replay the + enter transition on every switch among them. +- **Timeline ↔ timeline resizes in place.** `ColumnGeometry` places every + column (header cell, all-day bar, day column) in the layout pass; a switch + swaps the pager for a frame over both pages' days, each column lerped + between its slot on the one page and the other — a day the page doesn't + show carries on at its pitch, off screen, which is what slides it in or + out. Blocks sit in their column as shares of its width (`LaneColumn`), so + nothing recomposes per frame. The frame starts and ends exactly as the two + pages lay out, so the pagers either side hand over without a jump. +- **Across screens, what both show morphs.** Inside a `SharedTransitionLayout`, + an event shown in both views travels from its old bounds to its new ones, + keyed per occurrence *and* per day it is drawn from (`ViewMorphKey`); only a + pager's settled page carries the tags. The drawer, top bar and FAB are not + per screen: CalendarHost draws them once, above the switch, from what the + screen on show publishes (`PublishChrome` in `CalendarChrome.kt`), so a + switch only changes what they say. A tapped reminder notification routes through `MainActivity` (`singleTop` + `onNewIntent`) as an external detail key that `CalendarHost` consumes exactly like an event tap. diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 9577701e..d4086ea4 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -1,7 +1,7 @@ # Building from source -Calendula builds with the standard Android Gradle toolchain — no extra setup -beyond the SDK, a JDK, and the submodule. +Calendula builds with the standard Android Gradle toolchain. You need the SDK, +a JDK and the submodule, nothing else. ## Clone @@ -9,9 +9,9 @@ beyond the SDK, a JDK, and the submodule. git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git ``` -Calendula depends on **[floret-kit](https://codeberg.org/jlmakiola/floret-kit)**, +Calendula depends on [floret-kit](https://codeberg.org/jlmakiola/floret-kit), the shared Material 3 Expressive kit, as a git submodule wired in as a Gradle -composite build (`includeBuild("floret-kit")` in `settings.gradle.kts`) — it is +composite build (`includeBuild("floret-kit")` in `settings.gradle.kts`). It is compiled from source, not resolved from a repository. A clone without the submodule fails to configure. For an existing clone: @@ -21,14 +21,14 @@ git submodule update --init --recursive ## Requirements -- **JDK 17** — not newer; the Android Gradle Plugin requires exactly 17. If your +- **JDK 17**, not newer: the Android Gradle Plugin requires exactly 17. If your default JDK differs, set `JAVA_HOME` explicitly. -- **Android SDK** — platform **37** (`compileSdk`) and **build-tools 36.0.0**. +- **Android SDK**: platform 37 (`compileSdk`) and build-tools 36.0.0. `minSdk` is 29, `targetSdk` 36. The SDK is located via `ANDROID_HOME` (or `ANDROID_SDK_ROOT`), or via a gitignored `local.properties` with `sdk.dir`. If you use `local.properties`, note -that the composite build needs **its own** copy at `floret-kit/local.properties`; +that the composite build needs its own copy at `floret-kit/local.properties`; setting `ANDROID_HOME` covers both builds at once and is the simpler route. The Gradle wrapper is checked in, so you don't need a system Gradle. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 3223d9ae..adfd8a34 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -26,7 +26,7 @@ Applies to the Android app **Calendula** (package `de.jeanlucmakiola.calendula`) ## In short -Calendula collects nothing, sends nothing, and has no user accounts. It has **no internet permission at all** — the app is technically incapable of transmitting your data anywhere. Everything it shows you is read from the calendars that already exist on your device. +Calendula collects nothing, sends nothing, and has no user accounts. It has **no internet permission at all**, so it cannot transmit your data anywhere. Everything it shows you is read from the calendars that already exist on your device. ## 1. Controller @@ -47,9 +47,9 @@ All of the following is processed **locally on your device only**. None of it is ### Calendar data -Calendula is a viewer and editor for the calendars Android already manages. It reads and writes events, reminders and calendar settings through Android's system calendar provider. The app keeps **no database of its own** — your events live in the system calendar, exactly where they lived before you installed Calendula, and they remain there if you uninstall it. +Calendula is a viewer and editor for the calendars Android already manages. It reads and writes events, reminders and calendar settings through Android's system calendar provider. The app keeps **no database of its own**. Your events live in the system calendar, where they were before you installed Calendula, and they stay there if you uninstall it. -Note: if one of those system calendars is itself synchronised with an online account (for example a Google account, or a CalDAV server via DAVx5), that synchronisation is performed by Android and that other app — not by Calendula. The privacy policy of the respective provider applies to it. +Note: if one of those system calendars is itself synchronised with an online account (for example a Google account, or a CalDAV server via DAVx5), that synchronisation is performed by Android and that other app, not by Calendula. The privacy policy of the respective provider applies to it. ### Contacts (optional) @@ -67,7 +67,7 @@ When you import or export an ICS file, Calendula reads or writes exactly the fil Your preferences (view options, theme, reminder defaults and similar) are stored locally on your device and are removed when you uninstall the app. -## 4. Crash reports — the only case where data can leave your device +## 4. Crash reports: the only case where data can leave your device If Calendula crashes, it offers to report the problem. Nothing is sent automatically. The report is copied to your clipboard and your browser is opened with the project's issue tracker, the text pre-filled. **You see the full content, you decide whether to submit it, and you can edit or discard it.** @@ -86,19 +86,19 @@ If you choose to submit it, the report becomes a public issue on the project's i ## 5. External links -The settings screen contains links to the source code, the licence, the issue tracker and a voluntary donation page (Ko-fi). Following one of these links opens your browser and leaves the app; the privacy policy of the respective website then applies. Calendula transmits no data of yours in the process — it only opens the address. +The settings screen contains links to the source code, the licence, the issue tracker and a voluntary donation page (Ko-fi). Following one of these links opens your browser and leaves the app; the privacy policy of the respective website then applies. Calendula transmits none of your data in the process; it only opens the address. ## 6. Permissions and why they exist -- `READ_CALENDAR`, `WRITE_CALENDAR` — display and edit your events; the core function. -- `POST_NOTIFICATIONS` — show reminders. -- `READ_CONTACTS` — optional, only for the “Contact special dates” feature. -- `USE_EXACT_ALARM`, `SCHEDULE_EXACT_ALARM` — deliver reminders at the exact time, including after snoozing. -- `RECEIVE_BOOT_COMPLETED` — re-register pending reminders after a restart. -- `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` — only to open the system dialog for the “Reliable delivery” setting. -- `WAKE_LOCK`, `FOREGROUND_SERVICE`, `ACCESS_NETWORK_STATE` — required by the Android system component used for scheduled background work (WorkManager). +- `READ_CALENDAR`, `WRITE_CALENDAR`: display and edit your events (the core function). +- `POST_NOTIFICATIONS`: show reminders. +- `READ_CONTACTS`: optional, only for the “Contact special dates” feature. +- `USE_EXACT_ALARM`, `SCHEDULE_EXACT_ALARM`: deliver reminders at the exact time, including after snoozing. +- `RECEIVE_BOOT_COMPLETED`: re-register pending reminders after a restart. +- `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`: only to open the system dialog for the “Reliable delivery” setting. +- `WAKE_LOCK`, `FOREGROUND_SERVICE`, `ACCESS_NETWORK_STATE`: required by the Android system component used for scheduled background work (WorkManager). -`ACCESS_NETWORK_STATE` allows reading *whether* a network connection exists — it does **not** permit using one. Without `INTERNET`, no connection is possible. +`ACCESS_NETWORK_STATE` allows reading *whether* a network connection exists. It does **not** permit using one. Without `INTERNET`, no connection is possible. ## 7. Distribution channels diff --git a/docs/README.md b/docs/README.md index 79d73940..7b4f047a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,17 +9,14 @@ Where to look for what: | [`ARCHITECTURE.md`](ARCHITECTURE.md) | Orientation tour: principles, layers, navigation, recurring-write / conflict / reminder pipelines, testing | | [`RELEASING.md`](RELEASING.md) | Release process: versioning, the merge-driven pipeline, the two-forge split, secrets, key custody | | [`../CHANGELOG.md`](../CHANGELOG.md) | Release history (Keep a Changelog, SemVer) | -| [Issues](https://codeberg.org/jlmakiola/calendula/issues) + [milestones](https://codeberg.org/jlmakiola/calendula/milestones) | **The roadmap.** What's planned, in progress, and shipped — a milestone maps to its `release/vX.Y.Z` branch | +| [Issues](https://codeberg.org/jlmakiola/calendula/issues) + [milestones](https://codeberg.org/jlmakiola/calendula/milestones) | The roadmap: what's planned, in progress and shipped. A milestone maps to its `release/vX.Y.Z` branch | | [`../.planning/PROJECT.md`](../.planning/PROJECT.md) | What the project is: core value, stack + version pins, constraints, naming, forge/release infrastructure | | [`design/`](design/) | Per-feature design notes kept for features whose provider behaviour is worth recording | -| [`../fastlane/metadata/android/`](../fastlane/metadata/android/) | Store metadata (single source of truth): descriptions, title, icon, screenshots (DE + EN). Harvested directly by the official F-Droid repo; transformed into the self-hosted repo layout at release time by [`../scripts/fastlane_to_fdroid_localized.sh`](../scripts/fastlane_to_fdroid_localized.sh) | +| [`../fastlane/metadata/android/`](../fastlane/metadata/android/) | Store metadata (single source of truth) for every app language, mapped in `store-locales.txt`: descriptions, title, graphics. Harvested directly by the official F-Droid repo, pushed to Google Play with every release, validated by [`../scripts/check_store_listing.py`](../scripts/check_store_listing.py); transformed into the self-hosted repo layout at release time by [`../scripts/fastlane_to_fdroid_localized.sh`](../scripts/fastlane_to_fdroid_localized.sh) | | [`../fdroid-metadata/`](../fdroid-metadata/) | App-level F-Droid control file (`*.yml`: Categories, License, links) for the self-hosted repo's `fdroid update` | -| [`fdroid-official/`](fdroid-official/) | Recipe + notes for publishing to the **official** F-Droid repo (reproducible build + developer-signed binary) | +| [`fdroid-official/`](fdroid-official/) | Recipe and notes for publishing to the official F-Droid repo (reproducible build + developer-signed binary) | -Conventions: planning lives in the **issue tracker**, not in this repository. The -`.planning/` files that predated it (a roadmap, a development-state snapshot, and -a per-milestone requirement checklist) are gone — issues and milestones say the -same thing without going stale. `PROJECT.md` is what remains, and it describes -the project rather than its plan. -`ARCHITECTURE.md` is the authoritative orientation tour: it is updated with the -code, and is the right place for a lesson learned about the calendar provider. +Planning lives in the issue tracker and its milestones, not in this repository. +`.planning/PROJECT.md` describes the project itself. +`ARCHITECTURE.md` is updated with the code and is the place to record a lesson +learned about the calendar provider. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 1d630046..2b227dbd 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -162,24 +162,34 @@ before. fastlane appears only as the Play Developer API client (`supply`), because the store listing already lives in `fastlane/metadata/android/` — the same tree the official F-Droid repo harvests. One metadata source, two stores. -**What gets uploaded per release:** the AAB, plus the per-version "What's New" -from `fastlane/metadata/android/en-US/changelogs/.txt` — the +**What gets uploaded per release:** the AAB, the per-version "What's New" +from `fastlane/metadata/android//changelogs/.txt` (the hand-written summary from step 3, which is why it must stay **under 500 -characters**: Play rejects a longer one. Listing text is -**not** touched — an accidental overwrite of a live listing triggers a Play -policy review. Sync it deliberately with `bundle exec fastlane listing`. +characters**), and the **whole store listing** — title, short and full +description, icon, feature graphic and screenshots for every locale — all in one +Play edit. Nothing about the Play listing is edited in the console any more: +change the files, and the next release pushes them. Unchanged images are +skipped (`sync_image_upload`). Every listing change goes through Play's review, +together with the bundle. To push a listing fix between releases, run +`bundle exec fastlane listing` (add `version_code:` if the track holds +more than one release). -**Screenshots and graphics are skipped**, because the committed assets satisfy -F-Droid but not Play: +The listing tree is guarded by `scripts/check_store_listing.py`, which CI runs +on every pull request: -| Asset | Committed | Play requires | -| --- | --- | --- | -| `phoneScreenshots/*.png` | 1280×2856, 32-bit RGBA | long edge ≤ 2× short edge (so ≤ 2560), 24-bit PNG, no alpha | -| `icon.png` | 512×512, 24-bit RGB | 512×512, 32-bit PNG | -| `featureGraphic.png` | *missing* | required, exactly 1024×500 | +| Rule | Play limit | +| --- | --- | +| `title.txt` / `short_description.txt` / `full_description.txt` | 30 / 80 / 4000 chars, all three present in every locale | +| `full_description.txt` | one line per paragraph — Play renders every newline | +| `images/icon.png` | 512×512, 32-bit PNG | +| `images/featureGraphic.png` | 1024×500, JPEG or 24-bit PNG | +| `images/*Screenshots/*` | JPEG or 24-bit PNG (no alpha), sides 320–3840, long edge ≤ 2× short, 2–8 per type | +| locale directories | exactly the ones in `fastlane/store-locales.txt`, which maps every app `values-*` to a Play locale code | -Until those are fixed, Play's graphics are managed by hand in the console. Then -pass `images:true` to the `listing` lane. +A locale without its own images falls back to en-US in both stores, so graphics +only need to exist there. Adding an app language means adding its line to +`fastlane/store-locales.txt` and its listing directory — CI fails until both +exist. Store text is not on Weblate; it is edited here. **Track.** Uploads go straight to `production` at a full rollout (`PLAY_RELEASE_STATUS=completed`). The merge to `main` is already the human diff --git a/fastlane/Fastfile b/fastlane/Fastfile index afdde8ae..e6be7733 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -8,9 +8,9 @@ # the one build whose byte-for-byte output is verified by a third party. # # What fastlane IS here for is `supply` — the Play Developer API client — because -# the store listing already lives in fastlane/metadata/android, which the -# official F-Droid repo harvests from the tagged source tree. One metadata tree, -# two stores. +# the store listing lives in fastlane/metadata/android, which the official +# F-Droid repo harvests from the tagged source tree. One metadata tree, two +# stores; nothing about the Play listing is edited in the console. default_platform(:android) @@ -21,8 +21,18 @@ def project_path(path) File.expand_path(path, File.expand_path("..", FastlaneCore::FastlaneFolder.path || ".")) end +# Listing text, graphics and screenshots all come from fastlane/metadata/android; +# scripts/check_store_listing.py keeps the tree within Play's rules on every PR. +LISTING = { + skip_upload_metadata: false, + skip_upload_images: false, + skip_upload_screenshots: false, + # Only re-upload graphics whose content changed. + sync_image_upload: true, +}.freeze + platform :android do - desc "Upload an already-built, already-signed AAB to Play" + desc "Upload an already-built, already-signed AAB to Play, with the store listing" lane :deploy do |options| aab = project_path(options[:aab] || "app/build/outputs/bundle/release/app-release.aab") UI.user_error!("AAB not found at #{aab}") unless File.exist?(aab) @@ -36,52 +46,29 @@ platform :android do # gets the bundle. Never let the uploader reach for the other artifact. skip_upload_apk: true, - # Listing text is synced deliberately via the `listing` lane, not on every - # release. Overwriting a live listing by accident triggers a Play policy - # review, which is not a quick revert. - skip_upload_metadata: true, - - # "What's New" DOES track every release. - # scripts/sync_changelog_to_fastlane.sh writes - # fastlane/metadata/android/en-US/changelogs/.txt from - # CHANGELOG.md, so Play shows exactly the text F-Droid does. + # "What's New" tracks every release: changelogs/.txt, the + # same file F-Droid shows. skip_upload_changelogs: false, - # See the `listing` lane for why the committed images can't go to Play yet. - skip_upload_images: true, - skip_upload_screenshots: true, - # Dry run: validate the edit against the API and roll it back instead of # committing it. Used for the first end-to-end rehearsal. validate_only: options[:dry_run].to_s == "true", + **LISTING, ) end - desc "Sync the store listing text (and, once the assets qualify, the images)" + desc "Push only the store listing, e.g. a listing fix between releases" lane :listing do |options| - # Deliberate and manual: `bundle exec fastlane listing`. Not wired into the - # release pipeline. - # - # Images stay off by default because the committed assets do not satisfy - # Play today, even though F-Droid accepts all of them: - # - # * phoneScreenshots are 1280x2856. Play caps the long edge at twice the - # short edge (2560 here), so every screenshot is rejected. - # * those PNGs are 32-bit RGBA. Play wants 24-bit PNG, no alpha. - # * icon.png is 512x512 but 24-bit RGB. Play's icon wants 32-bit PNG. - # * there is no featureGraphic.png. Play requires one, exactly 1024x500. - # - # Until that is fixed, the Play listing's graphics are managed by hand in the - # console. Pass images:true once the assets qualify. - upload_images = options[:images].to_s == "true" - + # supply still anchors the edit on a release: pass version_code: when the + # track holds more than one. supply( + track: options[:track] || "production", + version_code: options[:version_code], skip_upload_aab: true, skip_upload_apk: true, - skip_upload_metadata: false, skip_upload_changelogs: true, - skip_upload_images: !upload_images, - skip_upload_screenshots: !upload_images, + validate_only: options[:dry_run].to_s == "true", + **LISTING, ) end end diff --git a/fastlane/metadata/android/ar/changelogs/22200.txt b/fastlane/metadata/android/ar/changelogs/22200.txt new file mode 100644 index 00000000..d02bcf8e --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/22200.txt @@ -0,0 +1,10 @@ +جديد +• يتنقّل اليوم والأسبوع والشهر الآن بمُنزلق يتبع إصبعك. +• يعرض العرض متعدد الأيام عدداً قابلاً للتخصيص من الأيام جنباً إلى جنب. +• الساعات المرئية: يملأ نطاق ساعات تختاره الشاشة في عروض الجدول الزمني. + +تغييرات +• يبرز اليوم في شبكة الشهر، والتنقّل بين العروض يتحوّل الآن بسلاسة بدل القطع المفاجئ. + +إصلاحات +• يتوقف العنوان والموقع عن الزيادة بعد 255 حرفاً، كي لا تقطعهما خوادم CalDAV أثناء المزامنة. diff --git a/fastlane/metadata/android/ar/full_description.txt b/fastlane/metadata/android/ar/full_description.txt new file mode 100644 index 00000000..3935f80e --- /dev/null +++ b/fastlane/metadata/android/ar/full_description.txt @@ -0,0 +1,11 @@ +Calendula تطبيق تقويم مفتوح المصدر لنظام أندرويد. يعمل مباشرةً على مزوّد التقويم في النظام، لذا يظهر في Calendula كل ما يُزامَن مع جهازك (Nextcloud عبر DAVx5، وGoogle، والتقويمات المحلية، واشتراكات WebCal)، وتُزامَن التغييرات التي تجريها بالطريقة نفسها. + +يوفّر عرض الشهر والأسبوع واليوم وجدول الأعمال. يمكنك إنشاء الأحداث وتعديلها وحذفها، بما فيها الأحداث المتكررة، وضبط التكرار من خيارات جاهزة أو بقواعدك الخاصة. عند تعديل حدث متكرر تختار إن كان التعديل يخص هذا الحدث وحده، أو هذا الحدث وكل ما يليه، أو السلسلة كاملة. + +يعرض Calendula تذكيرات أحداثك كإشعارات. المس الإشعار لفتح الحدث. + +إذا فعّلت ذلك، يعرض Calendula أيضًا أعياد ميلاد جهات اتصالك وذكرياتهم السنوية وتواريخ أخرى. تُقرأ جهات الاتصال على جهازك ولا تُرفع أو تُعدَّل أبدًا. ينسخ Calendula التواريخ إلى تقويمات محلية تتحكم فيها أنت. + +التصميم Material 3 Expressive في كل مكان، مع ألوان ديناميكية وحركة وأشكال معبّرة. + +لا يحتوي Calendula على أي قياس عن بُعد أو تحليلات أو وصول إلى الشبكة. بياناتك لا تغادر جهازك أبدًا. diff --git a/fastlane/metadata/android/ar/short_description.txt b/fastlane/metadata/android/ar/short_description.txt new file mode 100644 index 00000000..9ad49ff3 --- /dev/null +++ b/fastlane/metadata/android/ar/short_description.txt @@ -0,0 +1 @@ +تقويم بتصميم Material 3 Expressive لنظام أندرويد دون أي وصول إلى الشبكة. diff --git a/fastlane/metadata/android/ar/title.txt b/fastlane/metadata/android/ar/title.txt new file mode 100644 index 00000000..4f9e6a5b --- /dev/null +++ b/fastlane/metadata/android/ar/title.txt @@ -0,0 +1 @@ +Calendula diff --git a/fastlane/metadata/android/be/changelogs/22200.txt b/fastlane/metadata/android/be/changelogs/22200.txt new file mode 100644 index 00000000..9ffa80e3 --- /dev/null +++ b/fastlane/metadata/android/be/changelogs/22200.txt @@ -0,0 +1,10 @@ +Новае +• Дзень, Тыдзень і Месяц цяпер перагортваюцца пэйджарам, які сочыць за пальцам. +• Шматдзённы выгляд паказвае наладжвальную колькасць дзён побач. +• Бачныя гадзіны: абраны дыяпазон гадзін запаўняе экран у выглядах з часавай шкалой. + +Змены +• Сёньня вылучаецца ў сетцы месяца, а пераключэнне выглядаў цяпер плаўна пераходзіць замест рэзкай змены. + +Выпраўлена +• Загалоўкі і месцы больш не растуць пасля 255 знакаў, каб серверы CalDAV не абразалі іх падчас сінхранізацыі. diff --git a/fastlane/metadata/android/be/full_description.txt b/fastlane/metadata/android/be/full_description.txt new file mode 100644 index 00000000..0a2ea27c --- /dev/null +++ b/fastlane/metadata/android/be/full_description.txt @@ -0,0 +1,11 @@ +Calendula — праграма-каляндар з адкрытым зыходным кодам для Android. Яна працуе непасрэдна з сістэмным пастаўшчыком календароў, таму ўсё, што сінхранізуецца з вашай прыладай (Nextcloud праз DAVx5, Google, лакальныя календары, падпіскі WebCal), з'яўляецца ў Calendula, а вашы змены сінхранізуюцца назад тым жа шляхам. + +Ёсць віды «Месяц», «Тыдзень», «Дзень» і «Парадак дня». Вы можаце ствараць, змяняць і выдаляць падзеі, у тым ліку паўтаральныя, і задаваць паўтор з гатовых варыянтаў або сваімі правіламі. Пры змене паўтаральнай падзеі вы выбіраеце, ці датычыцца змена толькі гэтай падзеі, яе і ўсіх наступных або ўсёй серыі. + +Calendula паказвае напаміны пра падзеі ў выглядзе апавяшчэнняў. Націсніце на апавяшчэнне, каб адкрыць падзею. + +Калі ўключыць гэту функцыю, Calendula пакажа і дні нараджэння, гадавіны ды іншыя даты вашых кантактаў. Кантакты чытаюцца на прыладзе, нікуды не адпраўляюцца і не змяняюцца. Calendula капіюе даты ў лакальныя календары, якімі кіруеце вы. + +Дызайн усюды выкананы ў Material 3 Expressive: дынамічныя колеры, выразная анімацыя і формы. + +У Calendula няма тэлеметрыі, аналітыкі і доступу да сеткі. Вашы даныя не пакідаюць прыладу. diff --git a/fastlane/metadata/android/be/short_description.txt b/fastlane/metadata/android/be/short_description.txt new file mode 100644 index 00000000..4136b005 --- /dev/null +++ b/fastlane/metadata/android/be/short_description.txt @@ -0,0 +1 @@ +Каляндар у стылі Material 3 Expressive для Android без доступу да сеткі. diff --git a/fastlane/metadata/android/be/title.txt b/fastlane/metadata/android/be/title.txt new file mode 100644 index 00000000..4f9e6a5b --- /dev/null +++ b/fastlane/metadata/android/be/title.txt @@ -0,0 +1 @@ +Calendula diff --git a/fastlane/metadata/android/cs-CZ/changelogs/22200.txt b/fastlane/metadata/android/cs-CZ/changelogs/22200.txt new file mode 100644 index 00000000..9afb1884 --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/changelogs/22200.txt @@ -0,0 +1,10 @@ +Novinky +• Den, Týden a Měsíc teď listují stránkovačem, který sleduje prst. +• Vícedenní zobrazení ukazuje nastavitelný počet dní vedle sebe. +• Viditelné hodiny: zvolený rozsah hodin vyplní obrazovku v zobrazeních s časovou osou. + +Změny +• Dnes vyniká v mřížce měsíce a přepínání zobrazení teď plynule přechází místo ostrého střihu. + +Opraveno +• Názvy a místa přestávají růst po 255 znacích, aby je servery CalDAV neoříznuly uprostřed synchronizace. diff --git a/fastlane/metadata/android/cs-CZ/full_description.txt b/fastlane/metadata/android/cs-CZ/full_description.txt new file mode 100644 index 00000000..0a26ea2a --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/full_description.txt @@ -0,0 +1,11 @@ +Calendula je open-source kalendář pro Android. Pracuje přímo se systémovým poskytovatelem kalendáře, takže vše, co se synchronizuje do vašeho zařízení (Nextcloud přes DAVx5, Google, místní kalendáře, odběry WebCal), se objeví v Calendule a vaše změny se stejnou cestou synchronizují zpět. + +Nabízí zobrazení měsíce, týdne, dne a agendy. Události můžete vytvářet, upravovat a mazat, včetně opakovaných, a opakování nastavit z předvoleb nebo vlastních pravidel. Při změně opakované události zvolíte, zda se změna týká jen této události, této a všech následujících, nebo celé série. + +Calendula zobrazuje připomínky událostí jako oznámení. Klepnutím na oznámení otevřete událost. + +Když to zapnete, Calendula zobrazí i narozeniny, výročí a další data vašich kontaktů. Kontakty se čtou v zařízení a nikdy se neodesílají ani nemění. Calendula kopíruje data do místních kalendářů, které máte pod kontrolou. + +Design je všude Material 3 Expressive, s dynamickými barvami a výrazným pohybem a tvary. + +Calendula nemá telemetrii, analytiku ani přístup k síti. Vaše data nikdy neopustí zařízení. diff --git a/fastlane/metadata/android/cs-CZ/short_description.txt b/fastlane/metadata/android/cs-CZ/short_description.txt new file mode 100644 index 00000000..6814190f --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/short_description.txt @@ -0,0 +1 @@ +Kalendář v Material 3 Expressive pro Android bez přístupu k síti. diff --git a/fastlane/metadata/android/cs-CZ/title.txt b/fastlane/metadata/android/cs-CZ/title.txt new file mode 100644 index 00000000..4f9e6a5b --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/title.txt @@ -0,0 +1 @@ +Calendula diff --git a/fastlane/metadata/android/de-DE/changelogs/22200.txt b/fastlane/metadata/android/de-DE/changelogs/22200.txt new file mode 100644 index 00000000..6e970756 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/22200.txt @@ -0,0 +1,10 @@ +Neu +• Tag, Woche und Monat lassen sich jetzt mit einem fingerfolgenden Pager wischen. +• Mehrtagesansicht: einstellbare Anzahl Tage nebeneinander. +• Sichtbare Stunden: ein gewählter Stundenbereich füllt den Bildschirm in Zeitleisten-Ansichten. + +Geändert +• Heute hebt sich im Monatsraster ab; der Ansichtswechsel geht jetzt ineinander über statt hart zu schneiden. + +Behoben +• Titel und Orte wachsen nicht mehr über 255 Zeichen hinaus, damit CalDAV-Server sie beim Sync nicht abschneiden. diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt index 96ec0e3c..be8e918a 100644 --- a/fastlane/metadata/android/de-DE/full_description.txt +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -1,23 +1,11 @@ -Calendula ist eine moderne, quelloffene Kalender-App für Android. Sie -arbeitet direkt auf dem System-Kalender-Provider — jede Quelle, die mit -deinem Gerät synchronisiert ist (Nextcloud über DAVx5, Google, lokal, -WebCal-Subscriptions), erscheint automatisch, und deine Änderungen -synchronisieren auf demselben Weg zurück. +Calendula ist eine quelloffene Kalender-App für Android. Sie arbeitet direkt mit dem Kalender-Provider des Systems. Alles, was mit deinem Gerät synchronisiert wird (Nextcloud über DAVx5, Google, lokale Kalender, WebCal-Abos), erscheint in Calendula, und deine Änderungen synchronisieren auf demselben Weg zurück. -Termine erstellen, bearbeiten und löschen — auch wiederkehrende, mit -wählbarer Reichweite (nur dieser Termin / dieser und alle folgenden / ganze -Serie) und einem einfachen Wiederholungs-Picker. Erinnerungen stellt -Calendula selbst als Benachrichtigung zu — ein Tipp darauf öffnet den -Termin. +Es gibt Monats-, Wochen-, Tages- und Agenda-Ansicht. Du kannst Termine erstellen, bearbeiten und löschen, auch wiederkehrende, und Wiederholungen aus Vorlagen oder eigenen Regeln festlegen. Änderst du einen wiederkehrenden Termin, wählst du, ob die Änderung nur für diesen Termin, für diesen und alle folgenden oder für die ganze Serie gilt. -Optional lassen sich Geburtstage, Jahrestage und weitere besondere Tage -deiner Kontakte im Kalender anzeigen. Du schaltest das selbst ein; Kontakte -werden nur auf deinem Gerät gelesen, nie hochgeladen und nie verändert — -Calendula spiegelt die Daten lediglich in lokale Kalender, die du voll -kontrollierst. +Calendula zeigt deine Terminerinnerungen als Benachrichtigungen. Ein Tipp darauf öffnet den Termin. -Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend, -mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen. +Wenn du es einschaltest, zeigt Calendula auch Geburtstage, Jahrestage und andere Daten deiner Kontakte. Die Kontakte werden auf deinem Gerät gelesen und nie hochgeladen oder verändert. Calendula kopiert die Daten in lokale Kalender, über die du bestimmst. -Datenschutz: keinerlei Telemetrie, kein Tracking, kein Netzwerkzugriff — -deine Daten bleiben auf dem Gerät. +Das Design ist durchgehend Material 3 Expressive, mit dynamischen Farben und ausdrucksstarken Animationen und Formen. + +Calendula hat keine Telemetrie, keine Analyse und keinen Netzwerkzugriff. Deine Daten verlassen das Gerät nie. diff --git a/fastlane/metadata/android/de-DE/images/icon.png b/fastlane/metadata/android/de-DE/images/icon.png deleted file mode 100644 index b4be2db8..00000000 Binary files a/fastlane/metadata/android/de-DE/images/icon.png and /dev/null differ diff --git a/fastlane/metadata/android/de-DE/images/phoneScreenshots/01-week.png b/fastlane/metadata/android/de-DE/images/phoneScreenshots/01-week.png deleted file mode 100644 index 1fcb9888..00000000 Binary files a/fastlane/metadata/android/de-DE/images/phoneScreenshots/01-week.png and /dev/null differ diff --git a/fastlane/metadata/android/de-DE/images/phoneScreenshots/02-month.png b/fastlane/metadata/android/de-DE/images/phoneScreenshots/02-month.png deleted file mode 100644 index 327fd7a5..00000000 Binary files a/fastlane/metadata/android/de-DE/images/phoneScreenshots/02-month.png and /dev/null differ diff --git a/fastlane/metadata/android/de-DE/images/phoneScreenshots/03-day.png b/fastlane/metadata/android/de-DE/images/phoneScreenshots/03-day.png deleted file mode 100644 index f8a8f52e..00000000 Binary files a/fastlane/metadata/android/de-DE/images/phoneScreenshots/03-day.png and /dev/null differ diff --git a/fastlane/metadata/android/de-DE/images/phoneScreenshots/04-detail.png b/fastlane/metadata/android/de-DE/images/phoneScreenshots/04-detail.png deleted file mode 100644 index f47e4aac..00000000 Binary files a/fastlane/metadata/android/de-DE/images/phoneScreenshots/04-detail.png and /dev/null differ diff --git a/fastlane/metadata/android/de-DE/images/phoneScreenshots/05-agenda.png b/fastlane/metadata/android/de-DE/images/phoneScreenshots/05-agenda.png deleted file mode 100644 index 0ee8e8ae..00000000 Binary files a/fastlane/metadata/android/de-DE/images/phoneScreenshots/05-agenda.png and /dev/null differ diff --git a/fastlane/metadata/android/de-DE/images/phoneScreenshots/06-onboarding.png b/fastlane/metadata/android/de-DE/images/phoneScreenshots/06-onboarding.png deleted file mode 100644 index b0ae4ba3..00000000 Binary files a/fastlane/metadata/android/de-DE/images/phoneScreenshots/06-onboarding.png and /dev/null differ diff --git a/fastlane/metadata/android/de-DE/short_description.txt b/fastlane/metadata/android/de-DE/short_description.txt index ffe532c4..2aa6fbe9 100644 --- a/fastlane/metadata/android/de-DE/short_description.txt +++ b/fastlane/metadata/android/de-DE/short_description.txt @@ -1 +1 @@ -Ein moderner Material-3-Expressive-Kalender für Android. +Ein Material-3-Expressive-Kalender für Android ohne Netzwerkzugriff. diff --git a/fastlane/metadata/android/en-GB/changelogs/21801.txt b/fastlane/metadata/android/en-GB/changelogs/21801.txt deleted file mode 100644 index e8f6b85d..00000000 --- a/fastlane/metadata/android/en-GB/changelogs/21801.txt +++ /dev/null @@ -1,2 +0,0 @@ -Fixed -• Week and day view: tapping an empty slot now creates the event at the hour you tapped, even after zooming the timeline or changing the hour height. diff --git a/fastlane/metadata/android/en-GB/changelogs/21900.txt b/fastlane/metadata/android/en-GB/changelogs/21900.txt deleted file mode 100644 index ffd61af3..00000000 --- a/fastlane/metadata/android/en-GB/changelogs/21900.txt +++ /dev/null @@ -1,6 +0,0 @@ -New -• Drag an event to reschedule it — press and hold it in month, week or day view and drop it on another time or day. Recurring events ask what the move applies to. -• Delete several search results at once: long-press a hit to start selecting, then delete them in one action. - -Changed -• First launch is now a short wizard. It can also set up automatic backup and let you pick the view Calendula opens on, both with previews. diff --git a/fastlane/metadata/android/en-GB/changelogs/21901.txt b/fastlane/metadata/android/en-GB/changelogs/21901.txt deleted file mode 100644 index 02611ece..00000000 --- a/fastlane/metadata/android/en-GB/changelogs/21901.txt +++ /dev/null @@ -1,3 +0,0 @@ -Added -• Drag to reschedule can now be turned off: Settings → Views → “Drag to reschedule”. It stays on by default; switched off, events can no longer be picked up in the month, week or day view. -• Calendula now speaks Brazilian Portuguese, thanks to a community translation. Pick it under Settings → Language. diff --git a/fastlane/metadata/android/en-GB/changelogs/21902.txt b/fastlane/metadata/android/en-GB/changelogs/21902.txt deleted file mode 100644 index 7d9cd37a..00000000 --- a/fastlane/metadata/android/en-GB/changelogs/21902.txt +++ /dev/null @@ -1,8 +0,0 @@ -Fixed -• Tapping an event in month view now opens the event instead of its day. -• An edit shows straight away when you re-open the event. -• The month widget's arrows no longer die after a few taps — an oversized update was killing the launcher's widget host. -• Jump-to-today in seamless weeks lands on the current week, and day/week view no longer run flush against the right edge. - -Changed -• A meeting you declined is struck through everywhere and no longer reminds you. diff --git a/fastlane/metadata/android/en-GB/changelogs/22003.txt b/fastlane/metadata/android/en-GB/changelogs/22003.txt deleted file mode 100644 index ec1e81ce..00000000 --- a/fastlane/metadata/android/en-GB/changelogs/22003.txt +++ /dev/null @@ -1,2 +0,0 @@ -Fixed -• A custom Agenda range no longer crashes the app in French. Choosing a custom range — or just typing a number into that field — closed the app on any phone set to French, and only a range of exactly one day escaped it. Other languages were never affected. diff --git a/fastlane/metadata/android/en-US/changelogs/22200.txt b/fastlane/metadata/android/en-US/changelogs/22200.txt new file mode 100644 index 00000000..35370ce8 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/22200.txt @@ -0,0 +1,10 @@ +New +• Day, Week and Month now page with a swipeable, finger-tracking pager. +• A multi-day view shows a configurable number of days side by side. +• Visible hours: fit a chosen range of hours to the screen in timeline views. + +Changed +• Today stands out in the month grid, and switching views now morphs between them instead of cutting. + +Fixed +• Titles and locations stop growing past 255 characters, so CalDAV servers can't cut them off mid-sync. diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 2ebf5367..091f0a2e 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -1,20 +1,11 @@ -Calendula is a modern, open-source calendar app for Android. It works -directly on the system calendar provider, so any source synced to your -device — Nextcloud via DAVx5, Google, local, WebCal subscriptions — shows up -automatically, and changes you make sync back the same way. +Calendula is an open-source calendar app for Android. It works directly on the system calendar provider, so anything synced to your device (Nextcloud via DAVx5, Google, local calendars, WebCal subscriptions) shows up in Calendula, and changes you make sync back the same way. -Create, edit and delete events, including recurring events with scoped -changes (only this event / this and all following / the whole series) and a -simple repeat picker. Calendula also delivers your event reminders as -notifications — tap one and you're on the event. +It has month, week, day and agenda views. You can create, edit and delete events, including recurring ones, and set repeats from presets or your own rules. When you change a recurring event, you choose whether the change applies to that one event, to it and all following ones, or to the whole series. -Optionally, show your contacts' birthdays, anniversaries and other special -dates in your calendar. You turn this on yourself; contacts are read only on -your device, are never uploaded, and are never changed — Calendula only -mirrors the dates into local calendars you fully control. +Calendula shows your event reminders as notifications. Tap one to open the event. -The differentiator is the design: real Material 3 Expressive throughout, -with dynamic color, expressive motion, and expressive shapes. +If you turn it on, Calendula also shows your contacts' birthdays, anniversaries and other dates. Contacts are read on your device and are never uploaded or changed. Calendula copies the dates into local calendars that you control. -Privacy: zero telemetry, no analytics, no network access — your data never -leaves the device. +The design is Material 3 Expressive throughout, with dynamic color and expressive motion and shapes. + +Calendula has no telemetry, no analytics and no network access. Your data never leaves the device. diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png new file mode 100644 index 00000000..69d349e0 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/featureGraphic.png differ diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png index b4be2db8..17d1e242 100644 Binary files a/fastlane/metadata/android/en-US/images/icon.png and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png index 79bfee15..8a4f5d14 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png index 987d7d21..8ee22a27 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/03-agenda.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/03-agenda.png new file mode 100644 index 00000000..c1709f9d Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/03-agenda.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/03-day.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/03-day.png deleted file mode 100644 index 5d1cce59..00000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/03-day.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png index 4417a69d..df27e5a0 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/05-agenda.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/05-agenda.png deleted file mode 100644 index 59586c7b..00000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/05-agenda.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/05-recurring.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/05-recurring.png new file mode 100644 index 00000000..ac002f98 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/05-recurring.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/06-calendars.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/06-calendars.png new file mode 100644 index 00000000..26111040 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/06-calendars.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png deleted file mode 100644 index d8540e63..00000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/07-dark.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/07-dark.png new file mode 100644 index 00000000..cf95fdb9 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/07-dark.png differ diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/01-week.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/01-week.png new file mode 100644 index 00000000..f86825df Binary files /dev/null and b/fastlane/metadata/android/en-US/images/tenInchScreenshots/01-week.png differ diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/02-month.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/02-month.png new file mode 100644 index 00000000..490c9fe1 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/tenInchScreenshots/02-month.png differ diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/03-agenda.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/03-agenda.png new file mode 100644 index 00000000..4d4b0797 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/tenInchScreenshots/03-agenda.png differ diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/04-detail.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/04-detail.png new file mode 100644 index 00000000..c723c298 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/tenInchScreenshots/04-detail.png differ diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/05-recurring.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/05-recurring.png new file mode 100644 index 00000000..52d1b21e Binary files /dev/null and b/fastlane/metadata/android/en-US/images/tenInchScreenshots/05-recurring.png differ diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/06-calendars.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/06-calendars.png new file mode 100644 index 00000000..9dfa0e72 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/tenInchScreenshots/06-calendars.png differ diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/07-dark.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/07-dark.png new file mode 100644 index 00000000..81ec3cb6 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/tenInchScreenshots/07-dark.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt index 658239d1..c7f2965b 100644 --- a/fastlane/metadata/android/en-US/short_description.txt +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -1 +1 @@ -A modern Material 3 Expressive calendar for Android. +A Material 3 Expressive calendar for Android with no network access. diff --git a/fastlane/metadata/android/es-ES/changelogs/22200.txt b/fastlane/metadata/android/es-ES/changelogs/22200.txt new file mode 100644 index 00000000..6f3f526a --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/22200.txt @@ -0,0 +1,10 @@ +Novedades +• Día, Semana y Mes se deslizan con un paginador que sigue tu dedo. +• Vista de varios días: muestra un número configurable de días uno junto a otro. +• Horas visibles: un rango de horas elegido llena la pantalla en las vistas de línea de tiempo. + +Cambios +• Hoy destaca en la cuadrícula del mes; cambiar de vista se transforma ahora en vez de cortar. + +Corregido +• Títulos y ubicaciones dejan de crecer tras 255 caracteres, para que CalDAV no los corte al sincronizar. diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt index 96d23d1e..e859260c 100644 --- a/fastlane/metadata/android/es-ES/full_description.txt +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -1,25 +1,11 @@ -Calendula es una aplicación de calendario moderna y de código abierto -para Android. Funciona directamente sobre el proveedor de calendario del -sistema: cualquier origen sincronizado en tu dispositivo — Nextcloud -mediante DAVx5, Google, calendarios locales, suscripciones WebCal — -aparece automáticamente, y los cambios que hagas se sincronizan de vuelta -del mismo modo. +Calendula es una aplicación de calendario de código abierto para Android. Funciona directamente sobre el proveedor de calendario del sistema, así que todo lo que se sincroniza con tu dispositivo (Nextcloud mediante DAVx5, Google, calendarios locales, suscripciones WebCal) aparece en Calendula, y los cambios que hagas se sincronizan de vuelta por el mismo camino. -Crea, edita y elimina eventos, incluidos los recurrentes con cambios -acotados (solo este evento / este y todos los siguientes / toda la serie) -y un sencillo selector de repetición. Calendula también entrega los -recordatorios de tus eventos como notificaciones: toca una y estarás en -el evento. +Tiene vistas de mes, semana, día y agenda. Puedes crear, editar y eliminar eventos, también los periódicos, y definir repeticiones con ajustes predefinidos o reglas propias. Al cambiar un evento periódico, eliges si el cambio se aplica solo a ese evento, a ese y todos los siguientes, o a toda la serie. -Si quieres, muestra en tu calendario los cumpleaños, aniversarios y otras -fechas especiales de tus contactos. Tú mismo lo activas; los contactos se -leen solo en tu dispositivo, nunca se suben y nunca se modifican — -Calendula solo refleja las fechas en calendarios locales que controlas -por completo. +Calendula muestra los recordatorios de tus eventos como notificaciones. Toca uno para abrir el evento. -El factor diferenciador es el diseño: auténtico Material 3 Expressive en -toda la aplicación, con Dynamic Color, movimiento expresivo y formas -expresivas. +Si lo activas, Calendula muestra también los cumpleaños, aniversarios y otras fechas de tus contactos. Los contactos se leen en tu dispositivo y nunca se suben ni se modifican. Calendula copia las fechas en calendarios locales que tú controlas. -Privacidad: cero telemetría, sin analíticas, sin acceso a la red — tus -datos nunca salen del dispositivo. +El diseño es Material 3 Expressive en todo momento, con color dinámico y movimiento y formas expresivos. + +Calendula no tiene telemetría, ni analíticas, ni acceso a la red. Tus datos nunca salen del dispositivo. diff --git a/fastlane/metadata/android/es-ES/images/icon.png b/fastlane/metadata/android/es-ES/images/icon.png deleted file mode 100644 index b4be2db8..00000000 Binary files a/fastlane/metadata/android/es-ES/images/icon.png and /dev/null differ diff --git a/fastlane/metadata/android/es-ES/images/phoneScreenshots/01-week.png b/fastlane/metadata/android/es-ES/images/phoneScreenshots/01-week.png deleted file mode 100644 index b494bd37..00000000 Binary files a/fastlane/metadata/android/es-ES/images/phoneScreenshots/01-week.png and /dev/null differ diff --git a/fastlane/metadata/android/es-ES/images/phoneScreenshots/02-month.png b/fastlane/metadata/android/es-ES/images/phoneScreenshots/02-month.png deleted file mode 100644 index 3291ee76..00000000 Binary files a/fastlane/metadata/android/es-ES/images/phoneScreenshots/02-month.png and /dev/null differ diff --git a/fastlane/metadata/android/es-ES/images/phoneScreenshots/03-day.png b/fastlane/metadata/android/es-ES/images/phoneScreenshots/03-day.png deleted file mode 100644 index b6475cb3..00000000 Binary files a/fastlane/metadata/android/es-ES/images/phoneScreenshots/03-day.png and /dev/null differ diff --git a/fastlane/metadata/android/es-ES/images/phoneScreenshots/04-detail.png b/fastlane/metadata/android/es-ES/images/phoneScreenshots/04-detail.png deleted file mode 100644 index cf52dc58..00000000 Binary files a/fastlane/metadata/android/es-ES/images/phoneScreenshots/04-detail.png and /dev/null differ diff --git a/fastlane/metadata/android/es-ES/images/phoneScreenshots/05-agenda.png b/fastlane/metadata/android/es-ES/images/phoneScreenshots/05-agenda.png deleted file mode 100644 index ee94ae27..00000000 Binary files a/fastlane/metadata/android/es-ES/images/phoneScreenshots/05-agenda.png and /dev/null differ diff --git a/fastlane/metadata/android/es-ES/images/phoneScreenshots/06-onboarding.png b/fastlane/metadata/android/es-ES/images/phoneScreenshots/06-onboarding.png deleted file mode 100644 index 5a1acecf..00000000 Binary files a/fastlane/metadata/android/es-ES/images/phoneScreenshots/06-onboarding.png and /dev/null differ diff --git a/fastlane/metadata/android/es-ES/short_description.txt b/fastlane/metadata/android/es-ES/short_description.txt index cce99191..37579bf7 100644 --- a/fastlane/metadata/android/es-ES/short_description.txt +++ b/fastlane/metadata/android/es-ES/short_description.txt @@ -1 +1 @@ -Un calendario moderno con Material 3 Expressive para Android. +Un calendario Material 3 Expressive para Android sin acceso a la red. diff --git a/fastlane/metadata/android/fr-FR/changelogs/22200.txt b/fastlane/metadata/android/fr-FR/changelogs/22200.txt new file mode 100644 index 00000000..1964571b --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/22200.txt @@ -0,0 +1,10 @@ +Nouveautés +• Jour, Semaine et Mois défilent désormais avec un pager qui suit le doigt. +• Vue multi-jours : affiche un nombre de jours réglable, côte à côte. +• Heures visibles : une plage d'heures choisie remplit l'écran dans les vues chronologiques. + +Modifié +• Aujourd'hui ressort dans la grille du mois ; changer de vue se fait en fondu au lieu d'une coupure nette. + +Corrigé +• Titres et lieux arrêtent de grandir après 255 caractères, pour que CalDAV ne les tronque plus en synchronisant. diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt new file mode 100644 index 00000000..7558828d --- /dev/null +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -0,0 +1,11 @@ +Calendula est une application de calendrier open source pour Android. Elle fonctionne directement sur le fournisseur de calendrier du système : tout ce qui est synchronisé sur votre appareil (Nextcloud via DAVx5, Google, calendriers locaux, abonnements WebCal) apparaît dans Calendula, et vos modifications sont synchronisées en retour de la même façon. + +Elle propose des vues mois, semaine, jour et agenda. Vous pouvez créer, modifier et supprimer des événements, y compris récurrents, et définir les répétitions à partir de modèles ou de vos propres règles. Quand vous modifiez un événement récurrent, vous choisissez si la modification s'applique à cet événement seul, à celui-ci et aux suivants, ou à toute la série. + +Calendula affiche les rappels de vos événements sous forme de notifications. Touchez-en une pour ouvrir l'événement. + +Si vous l'activez, Calendula affiche aussi les anniversaires, fêtes et autres dates de vos contacts. Les contacts sont lus sur votre appareil et ne sont jamais envoyés ni modifiés. Calendula copie les dates dans des calendriers locaux que vous contrôlez. + +Le design suit Material 3 Expressive partout, avec des couleurs dynamiques et des animations et formes expressives. + +Calendula n'a ni télémétrie, ni statistiques d'usage, ni accès au réseau. Vos données ne quittent jamais l'appareil. diff --git a/fastlane/metadata/android/fr-FR/short_description.txt b/fastlane/metadata/android/fr-FR/short_description.txt new file mode 100644 index 00000000..17c4ccad --- /dev/null +++ b/fastlane/metadata/android/fr-FR/short_description.txt @@ -0,0 +1 @@ +Un calendrier Material 3 Expressive pour Android, sans accès au réseau. diff --git a/fastlane/metadata/android/fr-FR/title.txt b/fastlane/metadata/android/fr-FR/title.txt new file mode 100644 index 00000000..4f9e6a5b --- /dev/null +++ b/fastlane/metadata/android/fr-FR/title.txt @@ -0,0 +1 @@ +Calendula diff --git a/fastlane/metadata/android/hu-HU/changelogs/22200.txt b/fastlane/metadata/android/hu-HU/changelogs/22200.txt new file mode 100644 index 00000000..ffe43857 --- /dev/null +++ b/fastlane/metadata/android/hu-HU/changelogs/22200.txt @@ -0,0 +1,10 @@ +Új +• A Nap, Hét és Hónap nézet mostantól ujjkövető lapozóval lapozható. +• A többnapos nézet beállítható számú napot jelenít meg egymás mellett. +• Látható órák: a kiválasztott óratartomány kitölti a képernyőt az idővonalas nézetekben. + +Változások +• A mai nap kiemelkedik a havi rácsban, és a nézetek közti váltás most lágyan átalakul ahelyett, hogy vágna. + +Javítva +• A címek és helyszínek 255 karakter után nem nőnek tovább, hogy a CalDAV-kiszolgálók ne vágják le őket szinkronizáláskor. diff --git a/fastlane/metadata/android/hu-HU/full_description.txt b/fastlane/metadata/android/hu-HU/full_description.txt new file mode 100644 index 00000000..c4626b24 --- /dev/null +++ b/fastlane/metadata/android/hu-HU/full_description.txt @@ -0,0 +1,11 @@ +A Calendula nyílt forráskódú naptáralkalmazás Androidra. Közvetlenül a rendszer naptárszolgáltatójával dolgozik, így minden, ami az eszközödre szinkronizálódik (Nextcloud a DAVx5-ön keresztül, Google, helyi naptárak, WebCal-előfizetések), megjelenik a Calendulában, a módosításaid pedig ugyanígy szinkronizálódnak vissza. + +Van havi, heti, napi és napirend nézete. Eseményeket hozhatsz létre, szerkeszthetsz és törölhetsz, ismétlődőket is, az ismétlést pedig sablonból vagy saját szabállyal állíthatod be. Ismétlődő esemény módosításakor kiválasztod, hogy a változás csak erre az eseményre, erre és az összes következőre, vagy a teljes sorozatra vonatkozzon. + +A Calendula értesítésként jeleníti meg az események emlékeztetőit. Koppints egyre, és megnyílik az esemény. + +Ha bekapcsolod, a Calendula a névjegyeid születésnapjait, évfordulóit és más dátumait is megmutatja. A névjegyeket az eszközön olvassa, soha nem tölti fel és nem módosítja őket. A Calendula a dátumokat az általad kezelt helyi naptárakba másolja. + +A dizájn mindenhol Material 3 Expressive, dinamikus színekkel, kifejező mozgással és formákkal. + +A Calendulában nincs telemetria, analitika és hálózati hozzáférés. Az adataid soha nem hagyják el az eszközt. diff --git a/fastlane/metadata/android/hu-HU/short_description.txt b/fastlane/metadata/android/hu-HU/short_description.txt new file mode 100644 index 00000000..715f2f28 --- /dev/null +++ b/fastlane/metadata/android/hu-HU/short_description.txt @@ -0,0 +1 @@ +Material 3 Expressive naptár Androidra, hálózati hozzáférés nélkül. diff --git a/fastlane/metadata/android/hu-HU/title.txt b/fastlane/metadata/android/hu-HU/title.txt new file mode 100644 index 00000000..4f9e6a5b --- /dev/null +++ b/fastlane/metadata/android/hu-HU/title.txt @@ -0,0 +1 @@ +Calendula diff --git a/fastlane/metadata/android/it-IT/changelogs/22200.txt b/fastlane/metadata/android/it-IT/changelogs/22200.txt new file mode 100644 index 00000000..7debbf9e --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/22200.txt @@ -0,0 +1,10 @@ +Novità +• Giorno, Settimana e Mese ora scorrono con un pager che segue il dito. +• Vista multi-giorno: mostra un numero configurabile di giorni affiancati. +• Ore visibili: un intervallo di ore scelto riempie lo schermo nelle viste a linea del tempo. + +Modifiche +• Oggi risalta nella griglia del mese; il cambio di vista ora si trasforma invece di tagliare bruscamente. + +Corretto +• Titoli e luoghi smettono di crescere oltre 255 caratteri, così CalDAV non li tronca più durante la sincronizzazione. diff --git a/fastlane/metadata/android/it-IT/full_description.txt b/fastlane/metadata/android/it-IT/full_description.txt index 6845c927..3c905b83 100644 --- a/fastlane/metadata/android/it-IT/full_description.txt +++ b/fastlane/metadata/android/it-IT/full_description.txt @@ -1,24 +1,11 @@ -Calendula è un'app di calendario moderna e open source per Android. -Funziona direttamente sul provider di calendario di sistema: qualsiasi -origine sincronizzata sul tuo dispositivo — Nextcloud tramite DAVx5, -Google, calendari locali, iscrizioni WebCal — compare automaticamente, e -le modifiche che apporti vengono sincronizzate a ritroso allo stesso -modo. +Calendula è un'app di calendario open source per Android. Funziona direttamente sul provider di calendario di sistema, quindi tutto ciò che è sincronizzato sul tuo dispositivo (Nextcloud tramite DAVx5, Google, calendari locali, iscrizioni WebCal) compare in Calendula, e le modifiche che fai vengono sincronizzate allo stesso modo. -Crea, modifica ed elimina eventi, compresi quelli ricorrenti con -modifiche mirate (solo questo evento / questo e tutti i successivi / -l'intera serie) e un semplice selettore di ripetizione. Calendula -recapita anche i promemoria dei tuoi eventi come notifiche: ne tocchi una -e sei subito sull'evento. +Ha le viste mese, settimana, giorno e agenda. Puoi creare, modificare ed eliminare eventi, anche ricorrenti, e impostare le ripetizioni da modelli o regole personalizzate. Quando modifichi un evento ricorrente, scegli se la modifica vale solo per quell'evento, per quello e tutti i successivi o per l'intera serie. -Se vuoi, mostra nel calendario i compleanni, gli anniversari e le altre -date speciali dei tuoi contatti. Sei tu ad attivarlo; i contatti vengono -letti solo sul tuo dispositivo, non vengono mai caricati e non vengono -mai modificati — Calendula si limita a rispecchiare le date in calendari -locali che controlli completamente. +Calendula mostra i promemoria degli eventi come notifiche. Toccane una per aprire l'evento. -La differenza sta nel design: vero Material 3 Expressive ovunque, con -Dynamic Color, animazioni espressive e forme espressive. +Se lo attivi, Calendula mostra anche compleanni, anniversari e altre date dei tuoi contatti. I contatti vengono letti sul dispositivo e non vengono mai caricati né modificati. Calendula copia le date in calendari locali che controlli tu. -Privacy: zero telemetria, nessuna analisi, nessun accesso alla rete — i -tuoi dati non lasciano mai il dispositivo. +Il design è Material 3 Expressive in ogni schermata, con colori dinamici e animazioni e forme espressive. + +Calendula non ha telemetria, analisi o accesso alla rete. I tuoi dati non lasciano mai il dispositivo. diff --git a/fastlane/metadata/android/it-IT/images/icon.png b/fastlane/metadata/android/it-IT/images/icon.png deleted file mode 100644 index b4be2db8..00000000 Binary files a/fastlane/metadata/android/it-IT/images/icon.png and /dev/null differ diff --git a/fastlane/metadata/android/it-IT/images/phoneScreenshots/01-week.png b/fastlane/metadata/android/it-IT/images/phoneScreenshots/01-week.png deleted file mode 100644 index 2c7ee622..00000000 Binary files a/fastlane/metadata/android/it-IT/images/phoneScreenshots/01-week.png and /dev/null differ diff --git a/fastlane/metadata/android/it-IT/images/phoneScreenshots/02-month.png b/fastlane/metadata/android/it-IT/images/phoneScreenshots/02-month.png deleted file mode 100644 index 9ef29332..00000000 Binary files a/fastlane/metadata/android/it-IT/images/phoneScreenshots/02-month.png and /dev/null differ diff --git a/fastlane/metadata/android/it-IT/images/phoneScreenshots/03-day.png b/fastlane/metadata/android/it-IT/images/phoneScreenshots/03-day.png deleted file mode 100644 index d3ad829e..00000000 Binary files a/fastlane/metadata/android/it-IT/images/phoneScreenshots/03-day.png and /dev/null differ diff --git a/fastlane/metadata/android/it-IT/images/phoneScreenshots/04-detail.png b/fastlane/metadata/android/it-IT/images/phoneScreenshots/04-detail.png deleted file mode 100644 index 0a5a3783..00000000 Binary files a/fastlane/metadata/android/it-IT/images/phoneScreenshots/04-detail.png and /dev/null differ diff --git a/fastlane/metadata/android/it-IT/images/phoneScreenshots/05-agenda.png b/fastlane/metadata/android/it-IT/images/phoneScreenshots/05-agenda.png deleted file mode 100644 index 96de82bd..00000000 Binary files a/fastlane/metadata/android/it-IT/images/phoneScreenshots/05-agenda.png and /dev/null differ diff --git a/fastlane/metadata/android/it-IT/images/phoneScreenshots/06-onboarding.png b/fastlane/metadata/android/it-IT/images/phoneScreenshots/06-onboarding.png deleted file mode 100644 index 9a6282f9..00000000 Binary files a/fastlane/metadata/android/it-IT/images/phoneScreenshots/06-onboarding.png and /dev/null differ diff --git a/fastlane/metadata/android/it-IT/short_description.txt b/fastlane/metadata/android/it-IT/short_description.txt index 763e2563..4c3076c0 100644 --- a/fastlane/metadata/android/it-IT/short_description.txt +++ b/fastlane/metadata/android/it-IT/short_description.txt @@ -1 +1 @@ -Un moderno calendario Material 3 Expressive per Android. +Un calendario Material 3 Expressive per Android senza accesso alla rete. diff --git a/fastlane/metadata/android/ja-JP/changelogs/22200.txt b/fastlane/metadata/android/ja-JP/changelogs/22200.txt new file mode 100644 index 00000000..76ee2e01 --- /dev/null +++ b/fastlane/metadata/android/ja-JP/changelogs/22200.txt @@ -0,0 +1,10 @@ +新機能 +• 日・週・月ビューが指に追従するページャーでめくれるようになりました。 +• マルチデイビューで、設定可能な日数を横に並べて表示します。 +• 表示時間帯: タイムライン表示で選んだ時間の範囲が画面いっぱいに表示されます。 + +変更点 +• 月表示で今日が目立つようになり、ビューの切り替えは切り替わるのではなく滑らかに変化するようになりました。 + +修正 +• タイトルと場所は255文字を超えて伸びなくなり、CalDAVサーバーが同期中に途中で切り詰めることがなくなりました。 diff --git a/fastlane/metadata/android/ja-JP/full_description.txt b/fastlane/metadata/android/ja-JP/full_description.txt new file mode 100644 index 00000000..7e4dbf14 --- /dev/null +++ b/fastlane/metadata/android/ja-JP/full_description.txt @@ -0,0 +1,11 @@ +Calendula は Android 向けのオープンソースのカレンダーアプリです。システムのカレンダープロバイダ上で直接動作するので、端末に同期されているもの(DAVx5 経由の Nextcloud、Google、ローカルカレンダー、WebCal の購読)はそのまま Calendula に表示され、加えた変更も同じ経路で同期されます。 + +月・週・日・予定リストの各表示があります。予定の作成・編集・削除ができ、繰り返しの予定も作れます。繰り返しはプリセットか独自のルールで設定できます。繰り返しの予定を変更するときは、この予定のみ、この予定以降すべて、シリーズ全体のどれに適用するかを選べます。 + +予定のリマインダーは通知として届きます。通知をタップするとその予定が開きます。 + +オンにすると、連絡先の誕生日や記念日などの日付もカレンダーに表示されます。連絡先は端末上で読み取るだけで、アップロードや変更は行いません。日付は、あなたが管理するローカルカレンダーにコピーされます。 + +デザインはすべて Material 3 Expressive で、ダイナミックカラーと表情豊かなモーションやシェイプを備えています。 + +テレメトリ、解析、ネットワークアクセスはありません。データが端末の外に出ることはありません。 diff --git a/fastlane/metadata/android/ja-JP/short_description.txt b/fastlane/metadata/android/ja-JP/short_description.txt new file mode 100644 index 00000000..579d9009 --- /dev/null +++ b/fastlane/metadata/android/ja-JP/short_description.txt @@ -0,0 +1 @@ +ネットワークにアクセスしない、Material 3 Expressive の Android カレンダー。 diff --git a/fastlane/metadata/android/ja-JP/title.txt b/fastlane/metadata/android/ja-JP/title.txt new file mode 100644 index 00000000..4f9e6a5b --- /dev/null +++ b/fastlane/metadata/android/ja-JP/title.txt @@ -0,0 +1 @@ +Calendula diff --git a/fastlane/metadata/android/nl/changelogs/22200.txt b/fastlane/metadata/android/nl/changelogs/22200.txt new file mode 100644 index 00000000..3e03b7b1 --- /dev/null +++ b/fastlane/metadata/android/nl/changelogs/22200.txt @@ -0,0 +1,10 @@ +Nieuw +• Dag, Week en Maand kun je nu doorbladeren met een pager die je vinger volgt. +• Een meerdaagse weergave toont een instelbaar aantal dagen naast elkaar. +• Zichtbare uren: een gekozen tijdsbereik vult het scherm in tijdlijnweergaven. + +Gewijzigd +• Vandaag valt op in het maandraster, en wisselen tussen weergaven gaat nu vloeiend in plaats van abrupt. + +Opgelost +• Titels en locaties stoppen met groeien na 255 tekens, zodat CalDAV-servers ze niet halverwege de synchronisatie afkappen. diff --git a/fastlane/metadata/android/nl/full_description.txt b/fastlane/metadata/android/nl/full_description.txt new file mode 100644 index 00000000..5472d0db --- /dev/null +++ b/fastlane/metadata/android/nl/full_description.txt @@ -0,0 +1,11 @@ +Calendula is een opensource agenda-app voor Android. De app werkt rechtstreeks met de agendaprovider van het systeem, dus alles wat met je toestel wordt gesynchroniseerd (Nextcloud via DAVx5, Google, lokale agenda's, WebCal-abonnementen) verschijnt in Calendula, en wijzigingen die je maakt worden op dezelfde manier teruggesynchroniseerd. + +De app heeft maand-, week-, dag- en agendaweergaven. Je kunt afspraken aanmaken, bewerken en verwijderen, ook terugkerende, en herhalingen instellen via voorinstellingen of eigen regels. Wanneer je een terugkerende afspraak wijzigt, kies je of de wijziging geldt voor die ene afspraak, voor deze en alle volgende, of voor de hele reeks. + +Calendula toont je afspraakherinneringen als meldingen. Tik erop om de afspraak te openen. + +Als je het inschakelt, toont Calendula ook verjaardagen, jubilea en andere data van je contacten. Contacten worden op je toestel gelezen en nooit geüpload of gewijzigd. Calendula kopieert de data naar lokale agenda's die jij beheert. + +Het ontwerp is volledig Material 3 Expressive, met dynamische kleuren en expressieve beweging en vormen. + +Calendula heeft geen telemetrie, geen analyse en geen netwerktoegang. Je gegevens verlaten het toestel nooit. diff --git a/fastlane/metadata/android/nl/short_description.txt b/fastlane/metadata/android/nl/short_description.txt new file mode 100644 index 00000000..01af49ab --- /dev/null +++ b/fastlane/metadata/android/nl/short_description.txt @@ -0,0 +1 @@ +Een Material 3 Expressive-agenda voor Android zonder netwerktoegang. diff --git a/fastlane/metadata/android/nl/title.txt b/fastlane/metadata/android/nl/title.txt new file mode 100644 index 00000000..4f9e6a5b --- /dev/null +++ b/fastlane/metadata/android/nl/title.txt @@ -0,0 +1 @@ +Calendula diff --git a/fastlane/metadata/android/pl-PL/changelogs/22200.txt b/fastlane/metadata/android/pl-PL/changelogs/22200.txt new file mode 100644 index 00000000..ac26d773 --- /dev/null +++ b/fastlane/metadata/android/pl-PL/changelogs/22200.txt @@ -0,0 +1,10 @@ +Nowości +• Dzień, Tydzień i Miesiąc przewijają się teraz pagerem, który podąża za palcem. +• Widok wielodniowy pokazuje ustawialną liczbę dni obok siebie. +• Widoczne godziny: wybrany zakres godzin wypełnia ekran w widokach osi czasu. + +Zmiany +• Dziś wyróżnia się w siatce miesiąca, a przełączanie widoków teraz płynnie się przenika zamiast ciąć. + +Poprawki +• Tytuły i lokalizacje przestają rosnąć po 255 znakach, dzięki czemu serwery CalDAV nie obcinają ich w trakcie synchronizacji. diff --git a/fastlane/metadata/android/pl-PL/full_description.txt b/fastlane/metadata/android/pl-PL/full_description.txt new file mode 100644 index 00000000..39501da3 --- /dev/null +++ b/fastlane/metadata/android/pl-PL/full_description.txt @@ -0,0 +1,11 @@ +Calendula to kalendarz open source na Androida. Działa bezpośrednio na systemowym dostawcy kalendarza, więc wszystko, co synchronizuje się z urządzeniem (Nextcloud przez DAVx5, Google, kalendarze lokalne, subskrypcje WebCal), pojawia się w Calendula, a Twoje zmiany synchronizują się z powrotem tą samą drogą. + +Ma widok miesiąca, tygodnia, dnia i agendy. Możesz tworzyć, edytować i usuwać wydarzenia, także cykliczne, a powtarzanie ustawić z gotowych opcji lub własnych reguł. Przy zmianie wydarzenia cyklicznego wybierasz, czy zmiana dotyczy tylko tego wydarzenia, tego i wszystkich następnych, czy całej serii. + +Calendula pokazuje przypomnienia o wydarzeniach jako powiadomienia. Dotknij powiadomienia, aby otworzyć wydarzenie. + +Jeśli to włączysz, Calendula pokaże też urodziny, rocznice i inne daty Twoich kontaktów. Kontakty są odczytywane na urządzeniu i nigdy nie są wysyłane ani zmieniane. Calendula kopiuje daty do lokalnych kalendarzy, nad którymi masz kontrolę. + +Wygląd to Material 3 Expressive w całej aplikacji, z dynamicznymi kolorami oraz ekspresyjnym ruchem i kształtami. + +Calendula nie ma telemetrii, analityki ani dostępu do sieci. Twoje dane nigdy nie opuszczają urządzenia. diff --git a/fastlane/metadata/android/pl-PL/short_description.txt b/fastlane/metadata/android/pl-PL/short_description.txt new file mode 100644 index 00000000..ddccf1f4 --- /dev/null +++ b/fastlane/metadata/android/pl-PL/short_description.txt @@ -0,0 +1 @@ +Kalendarz Material 3 Expressive na Androida bez dostępu do sieci. diff --git a/fastlane/metadata/android/pl-PL/title.txt b/fastlane/metadata/android/pl-PL/title.txt new file mode 100644 index 00000000..4f9e6a5b --- /dev/null +++ b/fastlane/metadata/android/pl-PL/title.txt @@ -0,0 +1 @@ +Calendula diff --git a/fastlane/metadata/android/pt-BR/changelogs/22200.txt b/fastlane/metadata/android/pt-BR/changelogs/22200.txt new file mode 100644 index 00000000..56105127 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/22200.txt @@ -0,0 +1,10 @@ +Novidades +• Dia, Semana e Mês agora avançam com um pager que acompanha o dedo. +• Visualização de vários dias: mostra um número configurável de dias lado a lado. +• Horas visíveis: um intervalo de horas escolhido preenche a tela nas visualizações de linha do tempo. + +Alterações +• Hoje se destaca na grade do mês; trocar de visualização agora se transforma em vez de cortar. + +Corrigido +• Títulos e locais param de crescer após 255 caracteres, para que servidores CalDAV não os cortem ao sincronizar. diff --git a/fastlane/metadata/android/pt-BR/full_description.txt b/fastlane/metadata/android/pt-BR/full_description.txt new file mode 100644 index 00000000..f2112269 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/full_description.txt @@ -0,0 +1,11 @@ +Calendula é um app de calendário de código aberto para Android. Ele funciona direto no provedor de calendário do sistema, então tudo o que é sincronizado no seu aparelho (Nextcloud via DAVx5, Google, calendários locais, assinaturas WebCal) aparece no Calendula, e as alterações que você faz voltam pelo mesmo caminho. + +Tem visualizações de mês, semana, dia e agenda. Você pode criar, editar e excluir eventos, inclusive recorrentes, e definir repetições por predefinições ou regras próprias. Ao alterar um evento recorrente, você escolhe se a mudança vale só para esse evento, para ele e os seguintes, ou para a série inteira. + +O Calendula mostra os lembretes dos seus eventos como notificações. Toque em uma para abrir o evento. + +Se você ativar, o Calendula também mostra aniversários, datas comemorativas e outras datas dos seus contatos. Os contatos são lidos no seu aparelho e nunca são enviados nem alterados. O Calendula copia as datas para calendários locais que você controla. + +O design é Material 3 Expressive em todo o app, com cores dinâmicas e movimentos e formas expressivos. + +O Calendula não tem telemetria, análises nem acesso à rede. Seus dados nunca saem do aparelho. diff --git a/fastlane/metadata/android/pt-BR/short_description.txt b/fastlane/metadata/android/pt-BR/short_description.txt new file mode 100644 index 00000000..eb21c221 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/short_description.txt @@ -0,0 +1 @@ +Um calendário Material 3 Expressive para Android sem acesso à rede. diff --git a/fastlane/metadata/android/pt-BR/title.txt b/fastlane/metadata/android/pt-BR/title.txt new file mode 100644 index 00000000..4f9e6a5b --- /dev/null +++ b/fastlane/metadata/android/pt-BR/title.txt @@ -0,0 +1 @@ +Calendula diff --git a/fastlane/metadata/android/pt-PT/changelogs/22200.txt b/fastlane/metadata/android/pt-PT/changelogs/22200.txt new file mode 100644 index 00000000..9afca350 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/22200.txt @@ -0,0 +1,10 @@ +Novidades +• Dia, Semana e Mês deslizam agora com um pager que acompanha o dedo. +• Vista de vários dias: mostra um número configurável de dias lado a lado. +• Horas visíveis: um intervalo de horas escolhido preenche o ecrã nas vistas de linha temporal. + +Alterações +• Hoje destaca-se na grelha do mês; mudar de vista transforma-se agora em vez de cortar. + +Corrigido +• Títulos e localizações deixam de crescer a partir dos 255 carateres, para que CalDAV não os corte ao sincronizar. diff --git a/fastlane/metadata/android/pt-PT/full_description.txt b/fastlane/metadata/android/pt-PT/full_description.txt new file mode 100644 index 00000000..0539f551 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/full_description.txt @@ -0,0 +1,11 @@ +O Calendula é uma aplicação de calendário de código aberto para Android. Funciona diretamente sobre o fornecedor de calendário do sistema, pelo que tudo o que é sincronizado no seu dispositivo (Nextcloud através do DAVx5, Google, calendários locais, subscrições WebCal) aparece no Calendula, e as alterações que fizer são sincronizadas de volta da mesma forma. + +Tem vistas de mês, semana, dia e agenda. Pode criar, editar e eliminar eventos, incluindo recorrentes, e definir repetições a partir de predefinições ou das suas próprias regras. Ao alterar um evento recorrente, escolhe se a alteração se aplica apenas a esse evento, a esse e aos seguintes, ou a toda a série. + +O Calendula mostra os lembretes dos seus eventos como notificações. Toque numa para abrir o evento. + +Se ativar esta opção, o Calendula mostra também os aniversários, datas comemorativas e outras datas dos seus contactos. Os contactos são lidos no dispositivo e nunca são enviados nem alterados. O Calendula copia as datas para calendários locais que controla. + +O design é Material 3 Expressive em toda a aplicação, com cores dinâmicas e movimentos e formas expressivos. + +O Calendula não tem telemetria, análises nem acesso à rede. Os seus dados nunca saem do dispositivo. diff --git a/fastlane/metadata/android/pt-PT/short_description.txt b/fastlane/metadata/android/pt-PT/short_description.txt new file mode 100644 index 00000000..eb21c221 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/short_description.txt @@ -0,0 +1 @@ +Um calendário Material 3 Expressive para Android sem acesso à rede. diff --git a/fastlane/metadata/android/pt-PT/title.txt b/fastlane/metadata/android/pt-PT/title.txt new file mode 100644 index 00000000..4f9e6a5b --- /dev/null +++ b/fastlane/metadata/android/pt-PT/title.txt @@ -0,0 +1 @@ +Calendula diff --git a/fastlane/metadata/android/ru-RU/changelogs/22200.txt b/fastlane/metadata/android/ru-RU/changelogs/22200.txt new file mode 100644 index 00000000..cf4e56a5 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/22200.txt @@ -0,0 +1,10 @@ +Новое +• День, Неделя и Месяц теперь листаются пейджером, который следует за пальцем. +• Многодневный вид показывает настраиваемое число дней рядом друг с другом. +• Видимые часы: выбранный диапазон часов заполняет экран в видах с временной шкалой. + +Изменения +• Сегодня выделяется в сетке месяца, а переключение видов теперь плавно перетекает вместо резкой смены. + +Исправлено +• Заголовки и места больше не растут после 255 символов, чтобы серверы CalDAV не обрезали их при синхронизации. diff --git a/fastlane/metadata/android/ru-RU/full_description.txt b/fastlane/metadata/android/ru-RU/full_description.txt new file mode 100644 index 00000000..62797bae --- /dev/null +++ b/fastlane/metadata/android/ru-RU/full_description.txt @@ -0,0 +1,11 @@ +Calendula — приложение-календарь с открытым исходным кодом для Android. Оно работает напрямую с системным поставщиком календарей, поэтому всё, что синхронизируется с вашим устройством (Nextcloud через DAVx5, Google, локальные календари, подписки WebCal), появляется в Calendula, а ваши изменения синхронизируются обратно тем же путём. + +Есть виды «Месяц», «Неделя», «День» и «Повестка». Вы можете создавать, изменять и удалять события, в том числе повторяющиеся, и задавать повторение из готовых вариантов или своими правилами. При изменении повторяющегося события вы выбираете, относится ли изменение только к этому событию, к нему и всем следующим или ко всей серии. + +Calendula показывает напоминания о событиях в виде уведомлений. Нажмите на уведомление, чтобы открыть событие. + +Если включить эту функцию, Calendula покажет и дни рождения, годовщины и другие даты ваших контактов. Контакты читаются на устройстве, никуда не отправляются и не изменяются. Calendula копирует даты в локальные календари, которыми управляете вы. + +Дизайн везде выполнен в Material 3 Expressive: динамические цвета, выразительная анимация и формы. + +В Calendula нет телеметрии, аналитики и доступа к сети. Ваши данные не покидают устройство. diff --git a/fastlane/metadata/android/ru-RU/short_description.txt b/fastlane/metadata/android/ru-RU/short_description.txt new file mode 100644 index 00000000..20db86fe --- /dev/null +++ b/fastlane/metadata/android/ru-RU/short_description.txt @@ -0,0 +1 @@ +Календарь в стиле Material 3 Expressive для Android без доступа к сети. diff --git a/fastlane/metadata/android/ru-RU/title.txt b/fastlane/metadata/android/ru-RU/title.txt new file mode 100644 index 00000000..4f9e6a5b --- /dev/null +++ b/fastlane/metadata/android/ru-RU/title.txt @@ -0,0 +1 @@ +Calendula diff --git a/fastlane/metadata/android/sk/changelogs/22200.txt b/fastlane/metadata/android/sk/changelogs/22200.txt new file mode 100644 index 00000000..2dec8b34 --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/22200.txt @@ -0,0 +1,10 @@ +Novinky +• Deň, Týždeň a Mesiac sa teraz dajú listovať stránkovačom, ktorý sleduje prst. +• Viacdňové zobrazenie ukazuje nastaviteľný počet dní vedľa seba. +• Viditeľné hodiny: zvolený rozsah hodín vyplní obrazovku v časových zobrazeniach. + +Zmeny +• Dnešok vyniká v mriežke mesiaca a prepínanie zobrazení teraz plynule prechádza namiesto ostrého strihu. + +Opravené +• Názvy a miesta prestávajú rásť po 255 znakoch, aby ich servery CalDAV nezrezali uprostred synchronizácie. diff --git a/fastlane/metadata/android/sk/full_description.txt b/fastlane/metadata/android/sk/full_description.txt new file mode 100644 index 00000000..e31e031d --- /dev/null +++ b/fastlane/metadata/android/sk/full_description.txt @@ -0,0 +1,11 @@ +Calendula je open-source kalendár pre Android. Pracuje priamo so systémovým poskytovateľom kalendára, takže všetko, čo sa synchronizuje do vášho zariadenia (Nextcloud cez DAVx5, Google, miestne kalendáre, odbery WebCal), sa zobrazí v Calendule a vaše zmeny sa rovnakou cestou synchronizujú späť. + +Ponúka zobrazenie mesiaca, týždňa, dňa a agendy. Udalosti môžete vytvárať, upravovať a odstraňovať vrátane opakujúcich sa a opakovanie nastaviť z predvolieb alebo vlastných pravidiel. Pri zmene opakujúcej sa udalosti si zvolíte, či sa zmena týka len tejto udalosti, tejto a všetkých nasledujúcich, alebo celej série. + +Calendula zobrazuje pripomienky udalostí ako upozornenia. Ťuknutím na upozornenie otvoríte udalosť. + +Keď to zapnete, Calendula zobrazí aj narodeniny, výročia a ďalšie dátumy vašich kontaktov. Kontakty sa čítajú v zariadení a nikdy sa neodosielajú ani nemenia. Calendula kopíruje dátumy do miestnych kalendárov, ktoré máte pod kontrolou. + +Dizajn je všade Material 3 Expressive, s dynamickými farbami a výrazným pohybom a tvarmi. + +Calendula nemá telemetriu, analytiku ani prístup k sieti. Vaše údaje nikdy neopustia zariadenie. diff --git a/fastlane/metadata/android/sk/short_description.txt b/fastlane/metadata/android/sk/short_description.txt new file mode 100644 index 00000000..e049710f --- /dev/null +++ b/fastlane/metadata/android/sk/short_description.txt @@ -0,0 +1 @@ +Kalendár v štýle Material 3 Expressive pre Android bez prístupu k sieti. diff --git a/fastlane/metadata/android/sk/title.txt b/fastlane/metadata/android/sk/title.txt new file mode 100644 index 00000000..4f9e6a5b --- /dev/null +++ b/fastlane/metadata/android/sk/title.txt @@ -0,0 +1 @@ +Calendula diff --git a/fastlane/metadata/android/zh-CN/changelogs/22200.txt b/fastlane/metadata/android/zh-CN/changelogs/22200.txt new file mode 100644 index 00000000..3ef8463f --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/22200.txt @@ -0,0 +1,10 @@ +新增 +• 日、周、月视图现在可以用跟手的分页手势滑动切换。 +• 多日视图并排显示可自定义天数。 +• 可见时段:在时间线视图中,选定的时间范围会铺满整个屏幕。 + +变更 +• “今天”在月视图网格中会突出显示,切换视图时会平滑过渡,而不是直接切换。 + +修复 +• 标题和地点超过255个字符后不再继续增长,避免CalDAV服务器在同步时将其截断。 diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt new file mode 100644 index 00000000..a3198042 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/full_description.txt @@ -0,0 +1,11 @@ +Calendula 是一款开源的 Android 日历应用。它直接基于系统日历提供程序工作,因此同步到设备上的所有内容(通过 DAVx5 同步的 Nextcloud、Google、本地日历、WebCal 订阅)都会显示在 Calendula 中,你所做的更改也会以同样的方式同步回去。 + +提供月、周、日和日程列表视图。你可以创建、编辑和删除日程,包括重复日程,重复规则可以从预设中选择,也可以自定义。修改重复日程时,你可以选择只修改这一次、修改这一次及之后所有,或修改整个系列。 + +Calendula 会以通知形式发送日程提醒。点按通知即可打开对应日程。 + +开启后,Calendula 还会显示联系人的生日、纪念日和其他日期。联系人只在设备上读取,不会上传,也不会被修改。Calendula 会把这些日期复制到由你管理的本地日历中。 + +整体设计采用 Material 3 Expressive,支持动态配色以及富有表现力的动效和形状。 + +Calendula 没有遥测、没有分析,也不访问网络。你的数据不会离开设备。 diff --git a/fastlane/metadata/android/zh-CN/short_description.txt b/fastlane/metadata/android/zh-CN/short_description.txt new file mode 100644 index 00000000..95942fcb --- /dev/null +++ b/fastlane/metadata/android/zh-CN/short_description.txt @@ -0,0 +1 @@ +一款采用 Material 3 Expressive 设计、不联网的 Android 日历。 diff --git a/fastlane/metadata/android/zh-CN/title.txt b/fastlane/metadata/android/zh-CN/title.txt new file mode 100644 index 00000000..4f9e6a5b --- /dev/null +++ b/fastlane/metadata/android/zh-CN/title.txt @@ -0,0 +1 @@ +Calendula diff --git a/fastlane/store-locales.txt b/fastlane/store-locales.txt new file mode 100644 index 00000000..370a09ea --- /dev/null +++ b/fastlane/store-locales.txt @@ -0,0 +1,21 @@ +# Android resource qualifier -> store listing directory under metadata/android/. +# Store codes must be ones Google Play accepts; F-Droid takes any of them. +# `default` is app/src/main/res/values/ (English). +# Checked by scripts/check_store_listing.py. +default en-US +ar ar +be be +cs cs-CZ +de de-DE +es es-ES +fr fr-FR +hu hu-HU +it it-IT +ja ja-JP +nl nl +pl pl-PL +pt pt-PT +pt-rBR pt-BR +ru ru-RU +sk sk +zh-rCN zh-CN diff --git a/fdroid-metadata/de.jeanlucmakiola.calendula.yml b/fdroid-metadata/de.jeanlucmakiola.calendula.yml index 2a90a44f..c6236532 100644 --- a/fdroid-metadata/de.jeanlucmakiola.calendula.yml +++ b/fdroid-metadata/de.jeanlucmakiola.calendula.yml @@ -1,7 +1,7 @@ AuthorName: Jean-Luc Makiola License: MIT Name: Calendula -Summary: A modern Material 3 Expressive calendar for Android. +Summary: A Material 3 Expressive calendar for Android with no network access. Categories: - Calendar & Agenda diff --git a/floret-kit b/floret-kit index 14c13bae..b7e73ee8 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 14c13bae6d846845791e8b32eca6d016d2524364 +Subproject commit b7e73ee880449ade8d6162b56654083887fe669b diff --git a/scripts/check_store_listing.py b/scripts/check_store_listing.py new file mode 100644 index 00000000..37d375d5 --- /dev/null +++ b/scripts/check_store_listing.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Validate fastlane/metadata/android against Google Play's listing rules. + +The tree is pushed to Play on every release (and harvested by F-Droid), so a +field over its cap, an image Play rejects, or a locale Play doesn't know would +fail the upload after the release has shipped everywhere else. + + - every locale directory is listed in fastlane/store-locales.txt, and every + app language (res/values-*) has an entry there and a directory + - title / short / full description exist, are non-empty and within Play's caps, + and the full description isn't hard-wrapped + - icon: 512x512 32-bit PNG; featureGraphic: 1024x500 JPEG or 24-bit PNG + - screenshots: JPEG or 24-bit PNG, sides 320..3840, long edge <= 2x short, + 2..8 per type +""" +import re +import struct +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +META = ROOT / "fastlane/metadata/android" +MAPPING = ROOT / "fastlane/store-locales.txt" +RES = ROOT / "app/src/main/res" + +TEXT_LIMITS = {"title.txt": 30, "short_description.txt": 80, "full_description.txt": 4000} +LISTING_ENTRIES = set(TEXT_LIMITS) | {"changelogs", "images", "video.txt"} +SCREENSHOT_DIRS = {"phoneScreenshots", "sevenInchScreenshots", "tenInchScreenshots", + "tvScreenshots", "wearScreenshots"} +IMAGE_EXTS = {".png", ".jpg", ".jpeg"} + +errors = [] + + +def error(msg): + errors.append(msg) + + +def rel(path): + return path.relative_to(ROOT) + + +def image_info(path): + """(width, height, has_alpha, bits_per_pixel) for a PNG or baseline JPEG.""" + data = path.read_bytes() + if data[:8] == b"\x89PNG\r\n\x1a\n": + width, height, depth, color_type = struct.unpack(">IIBB", data[16:26]) + channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}.get(color_type, 0) + has_alpha = color_type in (4, 6) or b"tRNS" in data[:data.find(b"IDAT")] + return width, height, has_alpha, depth * channels + if data[:2] == b"\xff\xd8": + i = 2 + while i + 9 < len(data): + if data[i] != 0xFF: + i += 1 + continue + marker = data[i + 1] + length = struct.unpack(">H", data[i + 2:i + 4])[0] + if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC): + height, width = struct.unpack(">HH", data[i + 5:i + 9]) + return width, height, False, 24 + i += 2 + length + return None + + +def check_mapping(): + mapping = {} + for line in MAPPING.read_text(encoding="utf-8").splitlines(): + line = line.split("#", 1)[0].strip() + if line: + qualifier, store = line.split() + mapping[qualifier] = store + + app_langs = {"default"} | { + d.name[len("values-"):] for d in RES.glob("values-*") + if re.fullmatch(r"values-[a-z]{2,3}(-r[A-Z]{2})?", d.name) + } + for q in sorted(app_langs - mapping.keys()): + error(f"app ships values-{q} but {rel(MAPPING)} has no store locale for it") + for q in sorted(mapping.keys() - app_langs): + error(f"{rel(MAPPING)} maps '{q}' but the app has no values-{q}") + + stores = set(mapping.values()) + dirs = {d.name for d in META.iterdir() if d.is_dir()} + for d in sorted(dirs - stores): + error(f"{rel(META / d)} is not a mapped store locale (see {rel(MAPPING)})") + for d in sorted(stores - dirs): + error(f"no listing for {d} — create {rel(META / d)}") + return sorted(dirs & stores) + + +def check_text(locale_dir): + for name, limit in TEXT_LIMITS.items(): + f = locale_dir / name + if not f.is_file(): + error(f"{rel(f)} is missing") + continue + text = f.read_text(encoding="utf-8").strip() + if not text: + error(f"{rel(f)} is empty") + elif len(text) > limit: + error(f"{rel(f)} is {len(text)} chars (Play limit {limit})") + if name == "full_description.txt" and re.search(r"[^\n]\n(?![\n•*-])", text): + error(f"{rel(f)} is hard-wrapped; Play shows every newline, so keep a paragraph on one line") + for entry in locale_dir.iterdir(): + if entry.name not in LISTING_ENTRIES: + error(f"{rel(entry)} is not a listing file supply knows") + + +def check_image(f, kind): + info = image_info(f) + if f.suffix.lower() not in IMAGE_EXTS or info is None: + error(f"{rel(f)} is not a PNG or JPEG") + return + width, height, has_alpha, bpp = info + if kind == "icon": + if (width, height) != (512, 512) or bpp != 32 or f.suffix.lower() != ".png": + error(f"{rel(f)} is {width}x{height} {bpp}-bit; Play wants a 512x512 32-bit PNG") + elif kind == "featureGraphic": + if (width, height) != (1024, 500): + error(f"{rel(f)} is {width}x{height}; Play wants exactly 1024x500") + if has_alpha: + error(f"{rel(f)} has an alpha channel; Play wants JPEG or 24-bit PNG") + else: + short, long = sorted((width, height)) + if short < 320 or long > 3840: + error(f"{rel(f)} is {width}x{height}; each side must be 320..3840") + if long > 2 * short: + error(f"{rel(f)} is {width}x{height}; the long edge may be at most 2x the short edge") + if has_alpha: + error(f"{rel(f)} has an alpha channel; Play wants JPEG or 24-bit PNG") + + +def check_images(locale_dir): + images = locale_dir / "images" + if not images.is_dir(): + return + for entry in sorted(images.iterdir()): + if entry.is_dir(): + if entry.name not in SCREENSHOT_DIRS: + error(f"{rel(entry)} is not a screenshot type supply knows") + continue + shots = sorted(p for p in entry.iterdir() if p.is_file()) + if not 2 <= len(shots) <= 8: + error(f"{rel(entry)} has {len(shots)} images; Play takes 2..8") + for shot in shots: + check_image(shot, "screenshot") + elif entry.stem in ("icon", "featureGraphic"): + check_image(entry, entry.stem) + else: + error(f"{rel(entry)} is not an image supply knows (icon, featureGraphic, *Screenshots/)") + + +def main(): + for locale in check_mapping(): + check_text(META / locale) + check_images(META / locale) + if errors: + for e in errors: + print(f"ERROR: {e}", file=sys.stderr) + print(f"\n{len(errors)} store listing problem(s).", file=sys.stderr) + return 1 + print("Store listing OK.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/fastlane_to_fdroid_localized.sh b/scripts/fastlane_to_fdroid_localized.sh index 99a54e82..d74d2205 100755 --- a/scripts/fastlane_to_fdroid_localized.sh +++ b/scripts/fastlane_to_fdroid_localized.sh @@ -15,7 +15,9 @@ # full_description.txt -> description.txt # title.txt -> name.txt # images/icon.png -> icon.png +# images/featureGraphic.png -> featureGraphic.png # images/phoneScreenshots/* -> phoneScreenshots/* +# images/tenInchScreenshots/* -> tenInchScreenshots/* # changelogs/.txt -> changelogs/.txt # (changelogs are seeded into the fastlane tree by # scripts/sync_changelog_to_fastlane.sh.) @@ -33,10 +35,13 @@ for locdir in "$SRC"/*/; do [ -f "$locdir/full_description.txt" ] && cp "$locdir/full_description.txt" "$dst/description.txt" [ -f "$locdir/title.txt" ] && cp "$locdir/title.txt" "$dst/name.txt" [ -f "$locdir/images/icon.png" ] && cp "$locdir/images/icon.png" "$dst/icon.png" - if [ -d "$locdir/images/phoneScreenshots" ]; then - mkdir -p "$dst/phoneScreenshots" - cp "$locdir"images/phoneScreenshots/* "$dst/phoneScreenshots/" - fi + [ -f "$locdir/images/featureGraphic.png" ] && cp "$locdir/images/featureGraphic.png" "$dst/featureGraphic.png" + for shots in phoneScreenshots tenInchScreenshots; do + if [ -d "$locdir/images/$shots" ]; then + mkdir -p "$dst/$shots" + cp "$locdir"images/$shots/* "$dst/$shots/" + fi + done # Per-version changelogs live in the same fastlane tree (see # scripts/sync_changelog_to_fastlane.sh) and map straight across. if [ -d "$locdir/changelogs" ]; then