Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55deb663da | ||
|
|
7eef12be44 | ||
|
|
dd0458d44e | ||
|
|
be797bb82d | ||
|
|
d66116eb8f | ||
|
|
9588698874 | ||
|
|
a3ee15bde7 | ||
|
|
64b8a2d1a0 | ||
|
|
fad4359857 | ||
|
|
a66227bd20 | ||
|
|
eaa938277b | ||
|
|
bb9fd994fc | ||
|
|
d387f3d7bf | ||
|
|
3bead4a21f | ||
|
|
422904c9b9 | ||
|
|
51b9c56c3c | ||
|
|
2af0f44fbe | ||
|
|
62ea5ce9d3 | ||
|
|
254fafe42b | ||
|
|
f9dd7a22b7 | ||
|
|
7d7d16ca31 | ||
|
|
6ed0b30230 | ||
|
|
8d3d28c240 | ||
|
|
f9738b0c23 | ||
|
|
39ca7d869a | ||
|
|
0c36ed2a75 | ||
|
|
e84378cc5b | ||
|
|
57dcda5b54 | ||
|
|
df33eca5f2 | ||
|
|
dc841a91e0 | ||
|
|
f7805cb0b5 | ||
|
|
33c61202d9 | ||
|
|
a2b6793406 | ||
|
|
eda616b42e | ||
|
|
7c17bf2ff4 |
@@ -21,9 +21,9 @@ labels:
|
||||
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||
- Android version:
|
||||
- Device:
|
||||
- Installed from: <!-- official F-Droid / the self-hosted repo / built from source -->
|
||||
- Installed from: <!-- official F-Droid / the self-hosted repo / Google Play / Codeberg APK or Obtainium / built from source -->
|
||||
- Affected calendar: <!-- Google, CalDAV (DAVx5, Nextcloud, …), on-device/local,
|
||||
subscribed/WebCal, birthdays — provider behaviour differs
|
||||
subscribed/WebCal, birthdays. Provider behaviour differs
|
||||
a lot per account type, so this often points straight at
|
||||
the cause -->
|
||||
- Time zone: <!-- only if the problem involves dates or all-day events -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
<!--
|
||||
Thanks for reporting a crash in Calendula!
|
||||
|
||||
If the app prefilled this for you, the crash report is already below — just add
|
||||
If the app prefilled this for you, the crash report is already below. Add
|
||||
what you were doing and submit. Otherwise, paste the report from your clipboard
|
||||
into the code block. The report contains only app/Android/device versions and the
|
||||
stack trace — no personal data or calendar content.
|
||||
stack trace, with no personal data or calendar content.
|
||||
-->
|
||||
|
||||
### What happened
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
<!-- Closes #123 — link the issue this implements or fixes. -->
|
||||
<!-- Closes #123: link the issue this implements or fixes. -->
|
||||
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
- **Split view opens with the month's agenda.** It starts at today in the current month; tapping a day filters the list, and the filter chip restores the full month. Swiping the list moves by day when filtered and by month otherwise ([#142]).
|
||||
- **Today stands out in the month grid.** Its whole cell has a distinct background; tapping Today briefly expands that cell and nudges its neighbours aside ([#241]).
|
||||
- **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
|
||||
- **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]).
|
||||
@@ -1787,3 +1788,4 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
<h1>Calendula</h1>
|
||||
|
||||
<p><strong>A modern Material 3 Expressive calendar for Android.</strong><br>
|
||||
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
|
||||
<p><strong>A Material 3 Expressive calendar for Android.</strong><br>
|
||||
It works on top of the system calendar and has no network access.</p>
|
||||
|
||||
<p>
|
||||
<a href="https://codeberg.org/jlmakiola/calendula/actions"><img src="https://codeberg.org/jlmakiola/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
|
||||
@@ -26,84 +26,89 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="16%" alt="Week view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="16%" alt="Month view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-day.png" width="16%" alt="Day view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="16%" alt="Event detail">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-agenda.png" width="16%" alt="Agenda view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="16%" alt="Reminder onboarding">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="13%" alt="Week view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="13%" alt="Month view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-agenda.png" width="13%" alt="Agenda view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="13%" alt="Event detail">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-recurring.png" width="13%" alt="Editing a recurring event">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-calendars.png" width="13%" alt="Calendars from your accounts">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/07-dark.png" width="13%" alt="Dark theme">
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
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`</sub>
|
||||
|
||||
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.
|
||||
|
||||
<sub>Official F-Droid, the self-hosted repo, and the Codeberg releases all share
|
||||
the same signing key, so you can switch freely between them without
|
||||
reinstalling. Google Play is the exception — see above.</sub>
|
||||
<sub>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).</sub>
|
||||
|
||||
## 📚 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
|
||||
|
||||
@@ -288,6 +288,15 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
|
||||
}
|
||||
|
||||
/** How many days the multi-day view shows side by side (#193). */
|
||||
val multiDayCount: Flow<Int> = store.data.map { prefs ->
|
||||
(prefs[MULTI_DAY_COUNT_KEY] ?: DEFAULT_MULTI_DAY_COUNT).coerceIn(MULTI_DAY_COUNTS)
|
||||
}
|
||||
|
||||
suspend fun setMultiDayCount(count: Int) {
|
||||
store.edit { it[MULTI_DAY_COUNT_KEY] = count.coerceIn(MULTI_DAY_COUNTS) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The hours the day, week and multi-day timelines fit to the screen when they
|
||||
* open (#322). Off by default; the hours survive switching it off.
|
||||
@@ -309,15 +318,6 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** How many days the multi-day view shows side by side (#193). */
|
||||
val multiDayCount: Flow<Int> = store.data.map { prefs ->
|
||||
(prefs[MULTI_DAY_COUNT_KEY] ?: DEFAULT_MULTI_DAY_COUNT).coerceIn(MULTI_DAY_COUNTS)
|
||||
}
|
||||
|
||||
suspend fun setMultiDayCount(count: Int) {
|
||||
store.edit { it[MULTI_DAY_COUNT_KEY] = count.coerceIn(MULTI_DAY_COUNTS) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 "+"
|
||||
|
||||
@@ -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,19 +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
|
||||
@@ -46,17 +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.MultiDayScreen
|
||||
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
|
||||
@@ -73,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,
|
||||
@@ -104,19 +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<String?>(null) }
|
||||
val switchScope = rememberCoroutineScope()
|
||||
val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move ->
|
||||
navigationVersion++
|
||||
viewStack = move(viewStack)
|
||||
}
|
||||
val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } }
|
||||
val onOpenDay: (LocalDate) -> Unit = { date ->
|
||||
pendingDayIso = date.toString()
|
||||
viewStack = viewStack.drillToDay()
|
||||
focus.navigateTo(date)
|
||||
switchTo { it.drillToDay() }
|
||||
}
|
||||
var pendingWeekIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val onOpenWeek: (LocalDate) -> Unit = { date ->
|
||||
pendingWeekIso = date.toString()
|
||||
viewStack = viewStack.drillToWeek()
|
||||
focus.navigateTo(date)
|
||||
switchTo { it.drillToWeek() }
|
||||
}
|
||||
|
||||
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||
@@ -266,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()
|
||||
@@ -296,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()
|
||||
}
|
||||
@@ -347,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,
|
||||
@@ -354,91 +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,
|
||||
initialDateIso = pendingWeekIso,
|
||||
onInitialDateConsumed = { pendingWeekIso = null },
|
||||
)
|
||||
CalendarView.MultiDay -> MultiDayScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
initialDateIso = pendingDayIso,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
ViewScreen.Month -> MonthScreen(
|
||||
onOpenDay = onOpenDay,
|
||||
onOpenWeek = onOpenWeek,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
ViewScreen.Agenda -> AgendaScreen(
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -464,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(
|
||||
@@ -484,6 +603,7 @@ fun CalendarHost(
|
||||
importForm = form
|
||||
detailKey = null
|
||||
},
|
||||
visible = detailKey != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -492,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(
|
||||
@@ -501,6 +621,7 @@ fun CalendarHost(
|
||||
onClose = { createDateIso = null },
|
||||
onSaved = { createDateIso = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
visible = createDateIso != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -509,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(
|
||||
@@ -521,6 +642,7 @@ fun CalendarHost(
|
||||
detailKey = null
|
||||
},
|
||||
onManageCalendars = { showCalendars = true },
|
||||
visible = editKey != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -529,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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -586,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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -598,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
|
||||
@@ -625,6 +749,7 @@ fun CalendarHost(
|
||||
showBackup = false
|
||||
showCalendars = false
|
||||
},
|
||||
visible = showBackup,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -636,3 +761,6 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
||||
save = { stack -> stack.map(CalendarView::ordinal) },
|
||||
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
|
||||
)
|
||||
|
||||
private fun localToday(): LocalDate =
|
||||
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
|
||||
@@ -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<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
todayInToolbar: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AgendaViewModel = hiltViewModel(),
|
||||
) {
|
||||
// Opens on the focused date and hands back the day scrolled to the top.
|
||||
val focus = LocalViewFocus.current
|
||||
EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) }
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
|
||||
val 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<AgendaDay>): List<LocalDate> =
|
||||
days.flatMap { day ->
|
||||
// Header, the placeholder or the events, then the gap.
|
||||
List(2 + day.events.size.coerceAtLeast(1)) { day.date }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
@@ -451,58 +440,3 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AgendaTopBar(
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
quickSwitchViews: List<CalendarView>,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
// A plain label rather than a CalendarTitleButton, so it takes that
|
||||
// one's inset and one-line clamp itself.
|
||||
Text(
|
||||
text = stringResource(R.string.view_agenda),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = AppBarSpacing.TitleInset),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(R.string.month_open_menu),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TodayAction(show = showTodayButton, onToday = onToday)
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = stringResource(R.string.search_action),
|
||||
)
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
cycle = quickSwitchViews,
|
||||
onCycle = onCycleView,
|
||||
)
|
||||
},
|
||||
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||
// it, but here the bar meets the header on the same surface and the
|
||||
// tint is what makes that seam look like a separate block (#186).
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<Dp> = if (rememberReduceMotion() ||
|
||||
LocalTimelineZoom.current.isPinching
|
||||
) {
|
||||
snap()
|
||||
} else {
|
||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement {
|
||||
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
|
||||
val dpSpec: FiniteAnimationSpec<Dp> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
val shareSpec: FiniteAnimationSpec<Float> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
|
||||
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
|
||||
val animatedY by animateDpAsState(y, dpSpec, label = "block-y")
|
||||
val animatedHeight by animateDpAsState(height, dpSpec, label = "block-height")
|
||||
return BlockPlacement(animatedX, animatedWidth, animatedY, animatedHeight)
|
||||
}
|
||||
|
||||
/** Seats a block across its [placement]'s share of a [LaneColumn]. */
|
||||
fun Modifier.laneSlot(placement: BlockPlacement): Modifier =
|
||||
then(LaneSlot(placement.xFraction, placement.widthFraction))
|
||||
|
||||
private data class LaneSlot(val x: Float, val width: Float) : ParentDataModifier {
|
||||
override fun Density.modifyParentData(parentData: Any?): Any = this@LaneSlot
|
||||
}
|
||||
|
||||
/**
|
||||
* A day column's blocks, each measured across the share of the column its
|
||||
* [laneSlot] gives it; anything else fills the column. The shares are applied
|
||||
* at layout time, so the column can change width every frame without its
|
||||
* blocks recomposing.
|
||||
*/
|
||||
@Composable
|
||||
fun LaneColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||
Layout(content = content, modifier = modifier) { measurables, constraints ->
|
||||
val width = constraints.maxWidth
|
||||
val loose = constraints.copy(minWidth = 0, minHeight = 0)
|
||||
val placed = measurables.map { measurable ->
|
||||
val slot = measurable.parentData as? LaneSlot
|
||||
if (slot == null) {
|
||||
measurable.measure(loose) to 0
|
||||
} else {
|
||||
val left = (slot.x * width).roundToInt()
|
||||
val right = ((slot.x + slot.width) * width).roundToInt()
|
||||
val laneWidth = (right - left).coerceAtLeast(0)
|
||||
measurable.measure(loose.copy(minWidth = laneWidth, maxWidth = laneWidth)) to left
|
||||
}
|
||||
}
|
||||
layout(width, constraints.maxHeight) {
|
||||
placed.forEach { (placeable, x) -> placeable.placeRelative(x, 0) }
|
||||
}
|
||||
}
|
||||
val animatedX by animateDpAsState(x, spec, label = "block-x")
|
||||
val animatedY by animateDpAsState(y, spec, label = "block-y")
|
||||
val animatedWidth by animateDpAsState(width, spec, label = "block-width")
|
||||
val animatedHeight by animateDpAsState(height, spec, label = "block-height")
|
||||
return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* What a calendar screen shows in the top bar, the FAB and the drawer, which the
|
||||
* host draws once for all of them (#184): switching screens changes what they
|
||||
* say rather than swapping one bar for another.
|
||||
*
|
||||
* @param titleDate the date the title's picker opens on, or null for a title
|
||||
* that is only a label.
|
||||
* @param drawerDate the date the drawer's picker opens on.
|
||||
* @param awayFromToday whether "today" is worth offering.
|
||||
*/
|
||||
@Immutable
|
||||
data class ScreenChrome(
|
||||
val title: String,
|
||||
val shortTitle: String = title,
|
||||
val titleDate: LocalDate?,
|
||||
val drawerDate: LocalDate,
|
||||
val awayFromToday: Boolean,
|
||||
val todayText: String,
|
||||
)
|
||||
|
||||
/** What the chrome does for the screen on show; read when used, so not observed. */
|
||||
class ScreenActions(
|
||||
val onToday: () -> Unit,
|
||||
val onJumpToDate: (LocalDate) -> Unit,
|
||||
val onCreate: () -> Unit,
|
||||
)
|
||||
|
||||
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
|
||||
@Stable
|
||||
class ChromeHost {
|
||||
var chrome by mutableStateOf<ScreenChrome?>(null)
|
||||
private set
|
||||
|
||||
var actions: ScreenActions? = null
|
||||
private set
|
||||
|
||||
internal fun publish(chrome: ScreenChrome, actions: ScreenActions) {
|
||||
this.chrome = chrome
|
||||
this.actions = actions
|
||||
}
|
||||
}
|
||||
|
||||
/** The host's chrome, and whether the screen reading it is the one on show rather than one leaving. */
|
||||
class ChromeSlot(val host: ChromeHost, val current: Boolean)
|
||||
|
||||
val LocalChromeSlot = staticCompositionLocalOf<ChromeSlot?> { null }
|
||||
|
||||
/** Hands [chrome] and [actions] to the host, while this screen is the one on show. */
|
||||
@Composable
|
||||
fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
|
||||
val slot = LocalChromeSlot.current ?: return
|
||||
if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
|
||||
}
|
||||
|
||||
/** The top bar every calendar screen shares. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CalendarTopBar(
|
||||
host: ChromeHost,
|
||||
selectedView: CalendarView,
|
||||
quickSwitchViews: List<CalendarView>,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
) {
|
||||
val chrome = host.chrome
|
||||
TopAppBar(
|
||||
title = {
|
||||
if (chrome != null) {
|
||||
CalendarTitleButton(
|
||||
title = chrome.title,
|
||||
currentDate = chrome.titleDate,
|
||||
onJumpToDate = { host.actions?.onJumpToDate?.invoke(it) },
|
||||
shortTitle = chrome.shortTitle,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(R.string.month_open_menu),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TodayAction(show = showTodayButton, onToday = { host.actions?.onToday?.invoke() })
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = stringResource(R.string.search_action),
|
||||
)
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
cycle = quickSwitchViews,
|
||||
onCycle = onCycleView,
|
||||
)
|
||||
},
|
||||
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||
// it, but here the bar meets the header on the same surface and the
|
||||
// tint is what makes that seam look like a separate block (#186).
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The FAB column every calendar screen shares. */
|
||||
@Composable
|
||||
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) {
|
||||
val chrome = host.chrome ?: return
|
||||
CalendarFabColumn(
|
||||
todayVisible = chrome.awayFromToday && !todayInToolbar,
|
||||
todayText = chrome.todayText,
|
||||
onToday = { host.actions?.onToday?.invoke() },
|
||||
onCreate = { host.actions?.onCreate?.invoke() },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A calendar screen's body under the shared chrome, on the ground the screen's
|
||||
* own Scaffold used to give it; the host's Scaffold has already made room for
|
||||
* the bars, so [content] gets no padding of its own.
|
||||
*/
|
||||
@Composable
|
||||
fun ScreenBody(modifier: Modifier = Modifier, content: @Composable (PaddingValues) -> Unit) {
|
||||
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
content(PaddingValues())
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -52,3 +53,19 @@ fun rememberCalendarPageSpec(): FiniteAnimationSpec<Float> =
|
||||
@Composable
|
||||
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
||||
MaterialTheme.motionScheme.defaultEffectsSpec()
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> rememberViewSwitchSpec(long: Boolean = false): FiniteAnimationSpec<T> =
|
||||
spring(
|
||||
dampingRatio = VIEW_SWITCH_DAMPING,
|
||||
stiffness = if (long) LONG_VIEW_SWITCH_STIFFNESS else VIEW_SWITCH_STIFFNESS,
|
||||
)
|
||||
|
||||
private const val VIEW_SWITCH_DAMPING = 0.9f
|
||||
private const val VIEW_SWITCH_STIFFNESS = 450f
|
||||
private const val LONG_VIEW_SWITCH_STIFFNESS = 340f
|
||||
|
||||
@@ -130,6 +130,15 @@ fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarVie
|
||||
fun List<CalendarView>.drillToDay(): List<CalendarView> =
|
||||
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
|
||||
|
||||
/** The screens the views are drawn by: the day, multi-day and week views share one timeline (#184). */
|
||||
enum class ViewScreen { Month, Timeline, Agenda }
|
||||
|
||||
val CalendarView.screen: ViewScreen
|
||||
get() = when (this) {
|
||||
CalendarView.Month -> ViewScreen.Month
|
||||
CalendarView.Agenda -> ViewScreen.Agenda
|
||||
CalendarView.Week, CalendarView.MultiDay, CalendarView.Day -> ViewScreen.Timeline
|
||||
}
|
||||
/** Push the week view over the month after a week-number tap. */
|
||||
fun List<CalendarView>.drillToWeek(): List<CalendarView> =
|
||||
if (lastOrNull() == CalendarView.Week) this else this + CalendarView.Week
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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<String, String>?,
|
||||
) {
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Where 00:00 sat in the last timeline shown, measured from the top of its
|
||||
* view's content, so the next timeline view opens on the same hours (#184): an
|
||||
* event in both keeps its place and only grows sideways.
|
||||
*/
|
||||
class TimelineScrollMemory {
|
||||
var midnightY: Float? = null
|
||||
}
|
||||
|
||||
val LocalTimelineScroll = staticCompositionLocalOf<TimelineScrollMemory?> { null }
|
||||
|
||||
/** A timeline's scroll, and where it opens before it can scroll there. */
|
||||
@Stable
|
||||
class TimelineScroll(val state: ScrollState, noonPending: Boolean, private val openingHour: Int?) {
|
||||
/** Whether it is still to be put on noon, which it only can be once laid out. */
|
||||
var noonPending by mutableStateOf(noonPending)
|
||||
internal set
|
||||
|
||||
/** The top of what it shows, or will once open, over [content] pixels in a [viewport] pixels high. */
|
||||
fun top(viewport: Float, content: Float): Float =
|
||||
if (noonPending) {
|
||||
(openingHour?.let { it * content / 24f } ?: (content - viewport) / 2f)
|
||||
.coerceIn(0f, (content - viewport).coerceAtLeast(0f))
|
||||
} else state.value.toFloat()
|
||||
}
|
||||
|
||||
val LocalOpeningTimeline = staticCompositionLocalOf<TimelineScroll?> { null }
|
||||
|
||||
/**
|
||||
* The scroll state of a timeline whose scrolling area starts [timelineTop] below
|
||||
* its view's content top. Opens where the last timeline left off, or on noon the
|
||||
* first time.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
|
||||
val memory = LocalTimelineScroll.current
|
||||
val density = LocalDensity.current
|
||||
val zoom = LocalTimelineZoom.current
|
||||
val topPx = with(density) { timelineTop.toPx() }
|
||||
val remembered = memory?.midnightY
|
||||
val scrollState = rememberScrollState(
|
||||
initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0,
|
||||
)
|
||||
val scroll = remember(scrollState) {
|
||||
TimelineScroll(
|
||||
scrollState,
|
||||
noonPending = remembered == null,
|
||||
openingHour = zoom.hours.takeIf { it.enabled }?.startHour,
|
||||
)
|
||||
}
|
||||
val currentTop by rememberUpdatedState(topPx)
|
||||
var firstOpen by remember(scrollState) { mutableStateOf(true) }
|
||||
LaunchedEffect(scrollState, zoom, zoom.hours) {
|
||||
if (firstOpen && remembered != null) {
|
||||
firstOpen = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
firstOpen = false
|
||||
val startHour = zoom.open()
|
||||
if (startHour == null) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
} else {
|
||||
// Fitted to the scroll viewport itself: the layout resolves scales
|
||||
// against the viewport plus the all-day strip, which would push the
|
||||
// end hour below the screen.
|
||||
val viewport = snapshotFlow { scrollState.viewportSize }.first { it > 0 }
|
||||
val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() })
|
||||
zoom.holdFit(hourHeight)
|
||||
val hourPx = with(density) { hourHeight.toPx() }
|
||||
val target = withTimeoutOrNull(FIT_LAYOUT_TIMEOUT_MILLIS) {
|
||||
snapshotFlow {
|
||||
startHourScroll(scrollState.maxValue, scrollState.viewportSize, startHour, hourPx)
|
||||
}.filterNotNull().first()
|
||||
} ?: (startHour * (scrollState.maxValue + scrollState.viewportSize) / 24)
|
||||
.coerceIn(0, scrollState.maxValue.coerceAtLeast(0))
|
||||
scrollState.scrollTo(target)
|
||||
}
|
||||
scroll.noonPending = false
|
||||
}
|
||||
if (memory != null) {
|
||||
LaunchedEffect(scrollState, memory) {
|
||||
snapshotFlow { currentTop - scrollState.value }.collect { memory.midnightY = it }
|
||||
}
|
||||
}
|
||||
return scroll
|
||||
}
|
||||
|
||||
/** How long an opening timeline waits to be laid out at its fitted hours before scrolling anyway. */
|
||||
private const val FIT_LAYOUT_TIMEOUT_MILLIS = 500L
|
||||
@@ -13,15 +13,11 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
@@ -285,31 +281,3 @@ internal fun anchoredScroll(
|
||||
* as a scroll stays one.
|
||||
*/
|
||||
private const val PINCH_SLOP = 0.08f
|
||||
|
||||
/**
|
||||
* Where a timeline opens (#322): scrolled to the start of the visible hours when
|
||||
* they are on, otherwise centred on noon. Re-runs when the visible-hours
|
||||
* preference changes, so a range picked in Settings shows on the way back.
|
||||
*/
|
||||
@Composable
|
||||
fun OpenTimelineScroll(scrollState: ScrollState, zoom: TimelineZoom) {
|
||||
val density = LocalDensity.current
|
||||
LaunchedEffect(zoom, zoom.hours) {
|
||||
val startHour = zoom.open()
|
||||
if (startHour == null) {
|
||||
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)
|
||||
} else {
|
||||
val (target, hourHeight) = snapshotFlow {
|
||||
val viewport = scrollState.viewportSize
|
||||
val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() })
|
||||
val hourPx = with(density) { hourHeight.toPx() }
|
||||
startHourScroll(scrollState.maxValue, viewport, startHour, hourPx)?.let { it to hourHeight }
|
||||
}.filterNotNull().first()
|
||||
scrollState.scrollTo(target)
|
||||
zoom.holdFit(hourHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* The date every calendar view is looking at (#184), so switching views keeps
|
||||
* the same days on screen and what is shown in both can travel between them.
|
||||
*
|
||||
* A view reads it once, as it comes on screen, and writes it back only when the
|
||||
* user moves: a view settling where it opened must not nudge it, or switching
|
||||
* back and forth would drift.
|
||||
*/
|
||||
@Stable
|
||||
class ViewFocus(initial: LocalDate) {
|
||||
var date by mutableStateOf(initial)
|
||||
/** Explicit navigation, including a request for the view already on screen. */
|
||||
var navigationVersion by mutableStateOf(0)
|
||||
private set
|
||||
|
||||
fun navigateTo(target: LocalDate) {
|
||||
date = target
|
||||
navigationVersion++
|
||||
}
|
||||
}
|
||||
|
||||
val ViewFocusSaver: Saver<ViewFocus, String> = Saver(
|
||||
save = { it.date.toString() },
|
||||
restore = { ViewFocus(LocalDate.parse(it)) },
|
||||
)
|
||||
|
||||
val LocalViewFocus = staticCompositionLocalOf<ViewFocus?> { null }
|
||||
|
||||
/**
|
||||
* Runs [enter] with the focused date once, as the view comes on screen, before
|
||||
* it reads its own position: its first frame is then already on that date.
|
||||
*/
|
||||
@Composable
|
||||
fun <T : Any> EnterOnFocus(key: T, enter: (LocalDate) -> Unit) {
|
||||
val focus = LocalViewFocus.current
|
||||
remember(key) { focus?.date?.also(enter) }
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.BoundsTransform
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.mutableStateSetOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* What the views share when switching between them (#184): an event visible in
|
||||
* both grows from where it sat into where it lands, a date label travels to its
|
||||
* new seat, and everything else fades through.
|
||||
*
|
||||
* An element is tagged with the day it is drawn *from*, not just its instance: a
|
||||
* multi-day event is drawn once per day, per week row or per timeline column,
|
||||
* and only one element per key may take part. The segment that starts on the
|
||||
* same day in both views is the one that travels; the rest fade where they are.
|
||||
*/
|
||||
sealed interface ViewMorphKey {
|
||||
data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey
|
||||
|
||||
data class Day(val date: LocalDate) : ViewMorphKey
|
||||
|
||||
|
||||
companion object {
|
||||
/** [event]'s occurrence as drawn from [day]. */
|
||||
fun event(event: EventInstance, day: LocalDate): Event =
|
||||
Event(event.eventId, event.start.toEpochMilliseconds(), day)
|
||||
}
|
||||
}
|
||||
|
||||
/** The view switch's scopes and specs, captured where they exist. Null under reduced motion. */
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
class ViewMorphScope(
|
||||
val shared: SharedTransitionScope,
|
||||
val visibility: AnimatedVisibilityScope,
|
||||
val boundsTransform: BoundsTransform,
|
||||
val fadeSpec: FiniteAnimationSpec<Float>,
|
||||
val clip: BelowTopBarClip,
|
||||
val stayingPut: StayingPut,
|
||||
/** Read while matching, so a tag can sit out without leaving the modifier chain. */
|
||||
val enabled: () -> Boolean = { true },
|
||||
) {
|
||||
/** This scope with its tags switched off while [enabled] is false. */
|
||||
fun gated(enabled: () -> Boolean): ViewMorphScope =
|
||||
ViewMorphScope(shared, visibility, boundsTransform, fadeSpec, clip, stayingPut, enabled)
|
||||
|
||||
internal val config = object : SharedTransitionScope.SharedContentConfig {
|
||||
override val SharedTransitionScope.SharedContentState.isEnabled: Boolean
|
||||
get() = enabled()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The events a view has but keeps out of the switch, being scrolled out of
|
||||
* sight: the other view leaves them untagged too, or its half would travel to
|
||||
* nowhere and snap into place at the end.
|
||||
*/
|
||||
class StayingPut {
|
||||
val keys = mutableStateSetOf<ViewMorphKey.Event>()
|
||||
}
|
||||
|
||||
/** Marks [key] as staying put while [stays], for as long as it is composed. */
|
||||
@Composable
|
||||
fun StayPut(key: ViewMorphKey.Event, stays: Boolean) {
|
||||
val stayingPut = LocalViewMorph.current?.stayingPut ?: return
|
||||
if (!stays) return
|
||||
DisposableEffect(stayingPut, key) {
|
||||
stayingPut.keys += key
|
||||
onDispose { stayingPut.keys -= key }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps what travels below the top bar, which every view has at the same height:
|
||||
* the overlay draws above the whole screen, so an event scrolled under the bar
|
||||
* would otherwise cross it. [top] is the bar's bottom edge in the shared layout.
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
class BelowTopBarClip : SharedTransitionScope.OverlayClip {
|
||||
var top = 0f
|
||||
|
||||
override fun getClipPath(
|
||||
sharedContentState: SharedTransitionScope.SharedContentState,
|
||||
bounds: Rect,
|
||||
layoutDirection: LayoutDirection,
|
||||
density: Density,
|
||||
): Path = Path().apply { addRect(Rect(-FAR, top, FAR, FAR)) }
|
||||
|
||||
private companion object {
|
||||
const val FAR = 1e6f
|
||||
}
|
||||
}
|
||||
|
||||
val LocalViewMorph = compositionLocalOf<ViewMorphScope?> { null }
|
||||
|
||||
/**
|
||||
* Whether a pager page may carry the morph tags: only the settled one. Its
|
||||
* neighbours are composed ahead and repeat some of its dates, which would both
|
||||
* claim its keys and fly in from off screen.
|
||||
*/
|
||||
fun morphPageActive(page: Int, settledPage: Int): Boolean = page == settledPage
|
||||
|
||||
/** Provides the view morph to [content] only while [active] — see [morphPageActive]. */
|
||||
@Composable
|
||||
fun ViewMorphPage(active: Boolean, content: @Composable () -> Unit) {
|
||||
val morph = LocalViewMorph.current
|
||||
CompositionLocalProvider(LocalViewMorph provides morph.takeIf { active }, content = content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tags an event chip, bar, block or row. Its bounds are shared and the two faces
|
||||
* cross-fade inside them; remeasured rather than scaled, so a title keeps its
|
||||
* real size and is clipped while there is no room, instead of smearing.
|
||||
*
|
||||
* Goes after the element's offset and before its size, so the travelling bounds
|
||||
* drive the measurement.
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier {
|
||||
val morph = LocalViewMorph.current ?: return this
|
||||
if (key in morph.stayingPut.keys) return this
|
||||
return with(morph.shared) {
|
||||
this@morphEvent.sharedBounds(
|
||||
sharedContentState = rememberSharedContentState(key, morph.config),
|
||||
animatedVisibilityScope = morph.visibility,
|
||||
enter = fadeIn(morph.fadeSpec),
|
||||
exit = fadeOut(morph.fadeSpec),
|
||||
boundsTransform = morph.boundsTransform,
|
||||
clipInOverlayDuringTransition = morph.clip,
|
||||
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||
zIndexInOverlay = 1f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Tags a day's number, which is small on both sides, so it scales on its way. */
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun Modifier.morphDay(date: LocalDate): Modifier {
|
||||
val morph = LocalViewMorph.current ?: return this
|
||||
return with(morph.shared) {
|
||||
this@morphDay.sharedBounds(
|
||||
sharedContentState = rememberSharedContentState(ViewMorphKey.Day(date), morph.config),
|
||||
animatedVisibilityScope = morph.visibility,
|
||||
enter = fadeIn(morph.fadeSpec),
|
||||
exit = fadeOut(morph.fadeSpec),
|
||||
boundsTransform = morph.boundsTransform,
|
||||
clipInOverlayDuringTransition = morph.clip,
|
||||
zIndexInOverlay = 1f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The view switch's travel, on the same spring as the timeline's columns resizing. */
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun rememberViewMorphBoundsTransform(): BoundsTransform {
|
||||
val spec = rememberViewSwitchSpec<Rect>()
|
||||
return remember(spec) { BoundsTransform { _, _ -> spec } }
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
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(current.label())
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.ui.week.AllDaySpan
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* The day view is a single-column slice of the week view (spec S3). It reuses the
|
||||
* week's [TimedBlock] and [AllDaySpan] layout primitives — for one day, all-day
|
||||
* spans collapse to a single column ([AllDaySpan.startCol] == [AllDaySpan.endCol]
|
||||
* == 0) and only their [AllDaySpan.lane] (vertical stacking) matters.
|
||||
*/
|
||||
sealed interface DayUiState {
|
||||
data object Loading : DayUiState
|
||||
data class Failure(val reason: FailureReason) : DayUiState
|
||||
data class Success(
|
||||
val date: LocalDate,
|
||||
val today: LocalDate,
|
||||
/** All-day/multi-day events covering this day, stacked by lane. */
|
||||
val allDay: List<AllDaySpan>,
|
||||
/** Timed events clipped to this day with overlap lanes resolved. */
|
||||
val timed: List<TimedBlock>,
|
||||
) : DayUiState
|
||||
}
|
||||
@@ -1,136 +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.common.PageStateCache
|
||||
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.filterNot
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.abs
|
||||
|
||||
/** How far from the requested day [DayViewModel.day] keeps other days cached. */
|
||||
private const val DAY_CACHE_DAYS = 7
|
||||
|
||||
@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)
|
||||
|
||||
/** The pager page the anchor day sits on. */
|
||||
val anchorPage: StateFlow<Int> = _date
|
||||
.map { dayPageFor(it) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = dayPageFor(todayDate),
|
||||
)
|
||||
|
||||
/**
|
||||
* The anchor day's state. Once loaded it never falls back to [DayUiState.Loading]:
|
||||
* moving the anchor keeps the last result until the new day arrives, since
|
||||
* the pages draw their own days and this only gates the failure screen.
|
||||
*/
|
||||
val state: StateFlow<DayUiState> = _date
|
||||
.flatMapLatest { date -> day(date).filterNot { it is DayUiState.Loading } }
|
||||
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = DayUiState.Loading,
|
||||
)
|
||||
|
||||
private val days = PageStateCache<LocalDate, DayUiState>(
|
||||
scope = viewModelScope,
|
||||
initial = DayUiState.Loading,
|
||||
// Far enough to cover the pages either side and a swipe back, so paging
|
||||
// through a month doesn't keep a month of queries around.
|
||||
keep = { cached, requested -> abs(cached.daysUntil(requested)) <= DAY_CACHE_DAYS },
|
||||
) { date ->
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(dayRange(date, zone)),
|
||||
) { calendars, instances ->
|
||||
buildState(date, calendars, instances)
|
||||
}
|
||||
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
}
|
||||
|
||||
/** The day [date], shared between its pager page and [state]. Main thread only. */
|
||||
fun day(date: LocalDate): StateFlow<DayUiState> = days.get(date)
|
||||
|
||||
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
||||
fun onPageSettled(page: Int) {
|
||||
if (dayPageFor(_date.value) != page) _date.value = dayForPage(page)
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
_date.value = todayDate
|
||||
}
|
||||
|
||||
/** Jump to a specific date (e.g. when opened from the month grid). */
|
||||
fun goToDate(date: LocalDate) {
|
||||
_date.value = date
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
day: LocalDate,
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): DayUiState {
|
||||
calendarListFailure(calendars)?.let { return DayUiState.Failure(it) }
|
||||
val days = listOf(day)
|
||||
val allDay = instances.filter { it.isAllDay }
|
||||
val timed = instances.filterNot { it.isAllDay }
|
||||
return DayUiState.Success(
|
||||
date = day,
|
||||
today = todayDate,
|
||||
allDay = layoutAllDay(allDay, days, zone),
|
||||
timed = layoutDay(timed, day, zone),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Half-open instant range covering the single calendar [date]. */
|
||||
internal fun dayRange(date: LocalDate, zone: TimeZone): ClosedRange<Instant> {
|
||||
val from = date.atStartOfDayIn(zone)
|
||||
val to = date.atTime(23, 59, 59).toInstant(zone)
|
||||
return from..to
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* A live, scaled-down Day view for the onboarding view chooser. Today's date is
|
||||
* real; only the events are stand-ins.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DayViewPreview(
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
val state = remember(today, zone) { sampleDayState(today, zone) }
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
// Half the scroll range is noon — the same centring the live view does.
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
}
|
||||
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
DaySuccess(
|
||||
state = state,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = state.allDayStripHeight(),
|
||||
dragController = rememberTimelineDragController(),
|
||||
onEventClick = {},
|
||||
onCreateAt = { _, _ -> },
|
||||
onDrop = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample day state, laid out through the same helpers the live view uses. */
|
||||
private fun sampleDayState(today: LocalDate, zone: TimeZone): DayUiState.Success {
|
||||
val days = listOf(today)
|
||||
val events = sampleTimelineEvents(days, today, zone)
|
||||
return DayUiState.Success(
|
||||
date = today,
|
||||
today = today,
|
||||
allDay = layoutAllDay(events.filter { it.isAllDay }, days, zone),
|
||||
timed = layoutDay(events, today, zone),
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -203,6 +203,7 @@ fun EventEditScreen(
|
||||
initialForm: EventForm? = null,
|
||||
initialFormSource: ImportSource = ImportSource.File,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
visible: Boolean = true,
|
||||
viewModel: EventEditViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||
@@ -285,7 +286,7 @@ fun EventEditScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.predictiveBack(onBack = close),
|
||||
modifier = Modifier.predictiveBack(onBack = close, visible = visible),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -23,7 +23,6 @@ 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.label
|
||||
import de.jeanlucmakiola.calendula.ui.day.DayViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||
@@ -73,7 +72,7 @@ internal fun ViewStep(
|
||||
height = PREVIEW_HEIGHT,
|
||||
dayCount = LocalMultiDayCount.current,
|
||||
)
|
||||
CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT)
|
||||
CalendarView.Day -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT, dayCount = 1)
|
||||
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -76,6 +76,7 @@ internal fun AppearanceScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean = true,
|
||||
) {
|
||||
var showTheme by remember { mutableStateOf(false) }
|
||||
var showBrandFont by remember { mutableStateOf(false) }
|
||||
@@ -96,6 +97,7 @@ internal fun AppearanceScreen(
|
||||
title = stringResource(R.string.settings_section_appearance),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
// Theme & colour
|
||||
GroupedRow(
|
||||
|
||||
@@ -41,6 +41,7 @@ internal fun EventFormScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean = true,
|
||||
) {
|
||||
var showDefaultDuration by remember { mutableStateOf(false) }
|
||||
// The calendar whose duration-override picker is open, if any.
|
||||
@@ -53,6 +54,7 @@ internal fun EventFormScreen(
|
||||
title = stringResource(R.string.settings_section_event_form),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
SettingsHint(stringResource(R.string.settings_form_fields_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<SettingsSection?>(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() }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -68,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) }
|
||||
@@ -85,6 +86,7 @@ internal fun ViewsScreen(
|
||||
title = stringResource(R.string.settings_section_views),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
val config = state.quickSwitchConfig
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.plus
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** A day column's place across the timeline, in pixels from its start edge. */
|
||||
@Immutable
|
||||
data class ColumnSlot(val left: Float, val width: Float) {
|
||||
val right: Float get() = left + width
|
||||
}
|
||||
|
||||
/**
|
||||
* [date]'s column on a page showing this span across [width] pixels, with [gap]
|
||||
* between columns. A date off the page carries on at the same pitch, off screen
|
||||
* on its side — where a view switch slides it in from, or out to.
|
||||
*/
|
||||
fun DaySpan.slot(date: LocalDate, width: Float, gap: Float): ColumnSlot {
|
||||
val pitch = (width + gap) / count
|
||||
return ColumnSlot(left = start.daysUntil(date) * pitch, width = pitch - gap)
|
||||
}
|
||||
|
||||
/** The last day of the span. */
|
||||
val DaySpan.end: LocalDate get() = start.plus(count - 1, DateTimeUnit.DAY)
|
||||
|
||||
/**
|
||||
* The timeline's columns: one page's, or — while switching between the day, the
|
||||
* multi-day and the week view (#184) — every day of both pages, each column
|
||||
* between where it sits in the one and where it sits in the other.
|
||||
*
|
||||
* @param progress how far from [from] to [to], read at layout time only.
|
||||
*/
|
||||
@Stable
|
||||
class ColumnGeometry(val from: DaySpan, val to: DaySpan, val progress: () -> Float) {
|
||||
|
||||
/** Every day either page shows, in order: the two overlap, so they run on unbroken. */
|
||||
val dates: List<LocalDate> = run {
|
||||
val first = minOf(from.start, to.start)
|
||||
val last = maxOf(from.end, to.end)
|
||||
(0..first.daysUntil(last)).map { first.plus(it, DateTimeUnit.DAY) }
|
||||
}
|
||||
|
||||
fun slot(date: LocalDate, width: Float, gap: Float): ColumnSlot {
|
||||
val a = from.slot(date, width, gap)
|
||||
if (from == to) return a
|
||||
val b = to.slot(date, width, gap)
|
||||
val p = progress()
|
||||
return ColumnSlot(lerp(a.left, b.left, p), lerp(a.width, b.width, p))
|
||||
}
|
||||
|
||||
/** The span of columns [range] covers, between its extent on each page. */
|
||||
fun extent(fromRange: ClosedRange<LocalDate>, toRange: ClosedRange<LocalDate>, width: Float, gap: Float): ColumnSlot {
|
||||
val a = from.extent(fromRange, width, gap)
|
||||
if (from == to && fromRange == toRange) return a
|
||||
val b = to.extent(toRange, width, gap)
|
||||
val p = progress()
|
||||
val left = lerp(a.left, b.left, p)
|
||||
return ColumnSlot(left, lerp(a.right, b.right, p) - left)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** One page's columns, standing still. */
|
||||
fun of(span: DaySpan): ColumnGeometry = ColumnGeometry(span, span) { 0f }
|
||||
}
|
||||
}
|
||||
|
||||
private fun DaySpan.extent(range: ClosedRange<LocalDate>, width: Float, gap: Float): ColumnSlot {
|
||||
val left = slot(range.start, width, gap).left
|
||||
return ColumnSlot(left, slot(range.endInclusive, width, gap).right - left)
|
||||
}
|
||||
|
||||
internal fun lerp(a: Float, b: Float, p: Float): Float = a + (b - a) * p
|
||||
|
||||
/**
|
||||
* Lays [content] out once per day of [geometry], each across its column. The
|
||||
* columns are placed in the layout pass, so a view switch moving them every
|
||||
* frame re-lays them out without recomposing them. Children fill the height
|
||||
* the layout is given, or wrap it when it has none.
|
||||
*/
|
||||
@Composable
|
||||
fun TimelineColumns(
|
||||
geometry: ColumnGeometry,
|
||||
gap: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (LocalDate) -> Unit,
|
||||
) {
|
||||
val dates = geometry.dates
|
||||
Layout(
|
||||
content = {
|
||||
dates.forEach { date ->
|
||||
key(date) { Box(propagateMinConstraints = true) { content(date) } }
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
) { measurables, constraints ->
|
||||
val width = constraints.maxWidth
|
||||
val gapPx = gap.toPx()
|
||||
val slots = dates.map { geometry.slot(it, width.toFloat(), gapPx) }
|
||||
val placeables = measurables.mapIndexed { i, measurable ->
|
||||
val left = slots[i].left.roundToInt()
|
||||
val columnWidth = (slots[i].right.roundToInt() - left).coerceAtLeast(0)
|
||||
measurable.measure(constraints.copy(minWidth = columnWidth, maxWidth = columnWidth))
|
||||
}
|
||||
val height = placeables.maxOfOrNull { it.height }?.coerceAtLeast(constraints.minHeight)
|
||||
?: constraints.minHeight
|
||||
layout(width, height) {
|
||||
placeables.forEachIndexed { i, placeable ->
|
||||
placeable.placeRelative(slots[i].left.roundToInt(), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An all-day bar as each page lays it out: the days it covers there, clipped to
|
||||
* the page, and its lane. A bar only one page has keeps its days on the other,
|
||||
* so it slides out with them rather than vanishing.
|
||||
*/
|
||||
@Immutable
|
||||
data class PlacedBar(
|
||||
val event: EventInstance,
|
||||
/** The day the bar is drawn from on the page it is tagged on, for the view morph. */
|
||||
val keyDate: LocalDate,
|
||||
val fromRange: ClosedRange<LocalDate>,
|
||||
val toRange: ClosedRange<LocalDate>,
|
||||
val fromLane: Int,
|
||||
val toLane: Int,
|
||||
)
|
||||
|
||||
/** [state]'s all-day bars, standing still. */
|
||||
internal fun staticBars(state: WeekUiState.Success): List<PlacedBar> =
|
||||
state.allDaySpans.map { span ->
|
||||
val range = state.days[span.startCol]..state.days[span.endCol]
|
||||
PlacedBar(span.event, range.start, range, range, span.lane, span.lane)
|
||||
}
|
||||
|
||||
/**
|
||||
* The all-day bars of both pages of a view switch, each once: matched by
|
||||
* occurrence, placed on each page as that page lays it out.
|
||||
*/
|
||||
internal fun morphBars(from: WeekUiState.Success?, to: WeekUiState.Success?): List<PlacedBar> {
|
||||
val fromBars = from?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() }
|
||||
val toBars = to?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() }
|
||||
return (fromBars.keys + toBars.keys).map { occurrence ->
|
||||
val a = fromBars[occurrence]
|
||||
val b = toBars[occurrence]
|
||||
PlacedBar(
|
||||
event = (a ?: b)!!.event,
|
||||
keyDate = (a ?: b)!!.keyDate,
|
||||
fromRange = (a ?: b)!!.fromRange,
|
||||
toRange = (b ?: a)!!.toRange,
|
||||
fromLane = (a ?: b)!!.fromLane,
|
||||
toLane = (b ?: a)!!.toLane,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun EventInstance.occurrence(): Pair<Long, Long> = eventId to start.toEpochMilliseconds()
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.runtime.Stable
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* A switch between two timeline views in progress (#184): the page on screen
|
||||
* when it started, the page it ends on, and how far between the two it is.
|
||||
*/
|
||||
@Stable
|
||||
internal class TimelineMorph(
|
||||
val fromKind: TimelineKind,
|
||||
val toKind: TimelineKind,
|
||||
val from: DaySpan,
|
||||
val to: DaySpan,
|
||||
val fromState: StateFlow<WeekUiState>,
|
||||
val toState: StateFlow<WeekUiState>,
|
||||
) {
|
||||
val progress = Animatable(0f)
|
||||
|
||||
/** Whether the columns change size a lot, from a single day to a week or back, and take longer for it. */
|
||||
val isLong: Boolean get() = maxOf(from.count, to.count) > LONG_SWITCH_RATIO * minOf(from.count, to.count)
|
||||
|
||||
/** Where each day's events come from: the page it was on, else the page it goes to. */
|
||||
fun sourceOf(date: LocalDate): Side = if (date in from.start..from.end) Side.From else Side.To
|
||||
|
||||
enum class Side { From, To }
|
||||
}
|
||||
|
||||
private const val LONG_SWITCH_RATIO = 3
|
||||
@@ -73,3 +73,35 @@ data class DaysGrid(override val dayCount: Int, val phase: Int) : PageGrid {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ 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
|
||||
@@ -55,6 +56,10 @@ 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
|
||||
@@ -89,14 +94,22 @@ abstract class PagedTimelineViewModel(
|
||||
// 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<LocalDate> = _anchor
|
||||
|
||||
val position: StateFlow<PagerPosition> =
|
||||
combine(_anchor, gridRule) { anchor, rule -> rule(anchor).let { PagerPosition(it, it.pageFor(anchor)) } }
|
||||
/**
|
||||
* 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<PagerPosition> =
|
||||
combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) }
|
||||
.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = initialRule(todayDate).let { PagerPosition(it, it.pageFor(todayDate)) },
|
||||
initialValue = positionOf(todayDate, initialRule),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -140,7 +153,7 @@ abstract class PagedTimelineViewModel(
|
||||
|
||||
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
||||
fun onPageSettled(page: Int) {
|
||||
val grid = position.value.grid
|
||||
val grid = rule.value(_anchor.value)
|
||||
if (grid.pageFor(_anchor.value) != page) _anchor.value = grid.startForPage(page)
|
||||
}
|
||||
|
||||
@@ -209,6 +222,20 @@ class MultiDayViewModel @Inject constructor(
|
||||
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;
|
||||
|
||||
@@ -50,6 +50,7 @@ internal fun WeekViewPreview(
|
||||
WeekSuccess(
|
||||
state = state,
|
||||
showWeekNumber = dayCount == 7,
|
||||
headerBias = if (dayCount == 1) TimelineKind.Day.headerBias else 0f,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = state.allDayStripHeight(),
|
||||
dragController = rememberTimelineDragController(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<AllDaySpan>): 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,
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -64,4 +64,18 @@ class WeekPagingTest {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 158 KiB |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/<versionCode>.txt` — the
|
||||
**What gets uploaded per release:** the AAB, the per-version "What's New"
|
||||
from `fastlane/metadata/android/<locale>/changelogs/<versionCode>.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:<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
|
||||
|
||||
@@ -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/<versionCode>.txt from
|
||||
# CHANGELOG.md, so Play shows exactly the text F-Droid does.
|
||||
# "What's New" tracks every release: changelogs/<versionCode>.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
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
Calendula تطبيق تقويم مفتوح المصدر لنظام أندرويد. يعمل مباشرةً على مزوّد التقويم في النظام، لذا يظهر في Calendula كل ما يُزامَن مع جهازك (Nextcloud عبر DAVx5، وGoogle، والتقويمات المحلية، واشتراكات WebCal)، وتُزامَن التغييرات التي تجريها بالطريقة نفسها.
|
||||
|
||||
يوفّر عرض الشهر والأسبوع واليوم وجدول الأعمال. يمكنك إنشاء الأحداث وتعديلها وحذفها، بما فيها الأحداث المتكررة، وضبط التكرار من خيارات جاهزة أو بقواعدك الخاصة. عند تعديل حدث متكرر تختار إن كان التعديل يخص هذا الحدث وحده، أو هذا الحدث وكل ما يليه، أو السلسلة كاملة.
|
||||
|
||||
يعرض Calendula تذكيرات أحداثك كإشعارات. المس الإشعار لفتح الحدث.
|
||||
|
||||
إذا فعّلت ذلك، يعرض Calendula أيضًا أعياد ميلاد جهات اتصالك وذكرياتهم السنوية وتواريخ أخرى. تُقرأ جهات الاتصال على جهازك ولا تُرفع أو تُعدَّل أبدًا. ينسخ Calendula التواريخ إلى تقويمات محلية تتحكم فيها أنت.
|
||||
|
||||
التصميم Material 3 Expressive في كل مكان، مع ألوان ديناميكية وحركة وأشكال معبّرة.
|
||||
|
||||
لا يحتوي Calendula على أي قياس عن بُعد أو تحليلات أو وصول إلى الشبكة. بياناتك لا تغادر جهازك أبدًا.
|
||||
@@ -0,0 +1 @@
|
||||
تقويم بتصميم Material 3 Expressive لنظام أندرويد دون أي وصول إلى الشبكة.
|
||||
@@ -0,0 +1 @@
|
||||
Calendula
|
||||
@@ -0,0 +1,11 @@
|
||||
Calendula — праграма-каляндар з адкрытым зыходным кодам для Android. Яна працуе непасрэдна з сістэмным пастаўшчыком календароў, таму ўсё, што сінхранізуецца з вашай прыладай (Nextcloud праз DAVx5, Google, лакальныя календары, падпіскі WebCal), з'яўляецца ў Calendula, а вашы змены сінхранізуюцца назад тым жа шляхам.
|
||||
|
||||
Ёсць віды «Месяц», «Тыдзень», «Дзень» і «Парадак дня». Вы можаце ствараць, змяняць і выдаляць падзеі, у тым ліку паўтаральныя, і задаваць паўтор з гатовых варыянтаў або сваімі правіламі. Пры змене паўтаральнай падзеі вы выбіраеце, ці датычыцца змена толькі гэтай падзеі, яе і ўсіх наступных або ўсёй серыі.
|
||||
|
||||
Calendula паказвае напаміны пра падзеі ў выглядзе апавяшчэнняў. Націсніце на апавяшчэнне, каб адкрыць падзею.
|
||||
|
||||
Калі ўключыць гэту функцыю, Calendula пакажа і дні нараджэння, гадавіны ды іншыя даты вашых кантактаў. Кантакты чытаюцца на прыладзе, нікуды не адпраўляюцца і не змяняюцца. Calendula капіюе даты ў лакальныя календары, якімі кіруеце вы.
|
||||
|
||||
Дызайн усюды выкананы ў Material 3 Expressive: дынамічныя колеры, выразная анімацыя і формы.
|
||||
|
||||
У Calendula няма тэлеметрыі, аналітыкі і доступу да сеткі. Вашы даныя не пакідаюць прыладу.
|
||||
@@ -0,0 +1 @@
|
||||
Каляндар у стылі Material 3 Expressive для Android без доступу да сеткі.
|
||||
@@ -0,0 +1 @@
|
||||
Calendula
|
||||
@@ -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í.
|
||||
@@ -0,0 +1 @@
|
||||
Kalendář v Material 3 Expressive pro Android bez přístupu k síti.
|
||||
@@ -0,0 +1 @@
|
||||
Calendula
|
||||
@@ -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.
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 198 KiB |
@@ -1 +1 @@
|
||||
Ein moderner Material-3-Expressive-Kalender für Android.
|
||||
Ein Material-3-Expressive-Kalender für Android ohne Netzwerkzugriff.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||