Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55deb663da | ||
|
|
7eef12be44 | ||
|
|
dd0458d44e | ||
|
|
be797bb82d | ||
|
|
d66116eb8f | ||
|
|
9588698874 | ||
|
|
a3ee15bde7 | ||
|
|
64b8a2d1a0 | ||
|
|
fad4359857 | ||
|
|
a66227bd20 | ||
|
|
eaa938277b | ||
|
|
bb9fd994fc |
@@ -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
|
||||
|
||||
@@ -66,6 +66,7 @@ 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
|
||||
@@ -446,6 +447,14 @@ fun CalendarHost(
|
||||
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,
|
||||
|
||||
@@ -99,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].
|
||||
*
|
||||
@@ -131,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.
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -16,6 +16,7 @@ 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
|
||||
|
||||
/**
|
||||
@@ -81,14 +82,20 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
} else {
|
||||
val (target, hourHeight) = snapshotFlow {
|
||||
val viewport = scrollState.viewportSize
|
||||
val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() })
|
||||
val hourPx = with(density) { hourHeight.toPx() }
|
||||
startHourScroll(scrollState.maxValue, viewport, startHour, hourPx)?.let { it to hourHeight }
|
||||
}.filterNotNull().first()
|
||||
scrollState.scrollTo(target)
|
||||
// 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
|
||||
}
|
||||
@@ -99,3 +106,6 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
|
||||
}
|
||||
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
|
||||
|
||||
@@ -53,7 +53,18 @@ class ViewMorphScope(
|
||||
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
|
||||
@@ -127,7 +138,7 @@ fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier {
|
||||
if (key in morph.stayingPut.keys) return this
|
||||
return with(morph.shared) {
|
||||
this@morphEvent.sharedBounds(
|
||||
sharedContentState = rememberSharedContentState(key),
|
||||
sharedContentState = rememberSharedContentState(key, morph.config),
|
||||
animatedVisibilityScope = morph.visibility,
|
||||
enter = fadeIn(morph.fadeSpec),
|
||||
exit = fadeOut(morph.fadeSpec),
|
||||
@@ -146,7 +157,7 @@ fun Modifier.morphDay(date: LocalDate): Modifier {
|
||||
val morph = LocalViewMorph.current ?: return this
|
||||
return with(morph.shared) {
|
||||
this@morphDay.sharedBounds(
|
||||
sharedContentState = rememberSharedContentState(ViewMorphKey.Day(date)),
|
||||
sharedContentState = rememberSharedContentState(ViewMorphKey.Day(date), morph.config),
|
||||
animatedVisibilityScope = morph.visibility,
|
||||
enter = fadeIn(morph.fadeSpec),
|
||||
exit = fadeOut(morph.fadeSpec),
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.compose.foundation.pager.PagerDefaults
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
|
||||
@@ -24,12 +23,10 @@ import de.jeanlucmakiola.calendula.ui.common.morphDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.morphEvent
|
||||
import de.jeanlucmakiola.calendula.ui.common.morphPageActive
|
||||
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
|
||||
import de.jeanlucmakiola.calendula.ui.common.pagedHeight
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
@@ -41,6 +38,7 @@ import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
@@ -140,6 +138,7 @@ import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
@@ -231,6 +230,14 @@ private fun rememberTodayRipple(active: Boolean): Animatable<Float, androidx.com
|
||||
return pulse
|
||||
}
|
||||
|
||||
/** Today's cell: a light primary tint over the ordinary cell, set apart without shouting (#241). */
|
||||
@Composable
|
||||
private fun todayCellColor(): Color =
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = TODAY_CELL_TINT)
|
||||
.compositeOver(MaterialTheme.colorScheme.surfaceContainer)
|
||||
|
||||
private const val TODAY_CELL_TINT = 0.45f
|
||||
|
||||
/** Paint-only motion keeps hit targets and the month grid's lane geometry stable. */
|
||||
private fun Modifier.todayRipple(relativeColumn: Int?, progress: () -> Float): Modifier {
|
||||
if (relativeColumn == null || abs(relativeColumn) > 1) return this
|
||||
@@ -717,10 +724,6 @@ private fun MonthContent(
|
||||
* carries the chip drag and the morph tags: its neighbours show some of its
|
||||
* dates among their leading and trailing days, and would otherwise claim its
|
||||
* drops and its shared elements.
|
||||
*
|
||||
* @param static draw the current page alone, without a pager — for a face of the
|
||||
* split style on its way out, which must not hold on to the pager state the
|
||||
* incoming face now drives.
|
||||
*/
|
||||
@Composable
|
||||
private fun MonthPager(
|
||||
@@ -728,16 +731,9 @@ private fun MonthPager(
|
||||
weekStart: DayOfWeek,
|
||||
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
||||
modifier: Modifier = Modifier,
|
||||
static: Boolean = false,
|
||||
loading: @Composable () -> Unit = { MonthGridLoading() },
|
||||
page: @Composable (index: Int, state: MonthUiState.Success) -> Unit,
|
||||
) {
|
||||
if (static) {
|
||||
Box(modifier) {
|
||||
MonthPage(pagerState.currentPage, true, true, weekStart, monthState, loading, page)
|
||||
}
|
||||
return
|
||||
}
|
||||
val chipDrag = LocalMonthDrag.current
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
@@ -1295,13 +1291,16 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
|
||||
|
||||
/**
|
||||
* Split style content: the compact grid keeps the month swipe, the pane below it
|
||||
* lists whatever day is selected — and a downward drag trades the pane away for
|
||||
* the full paged grid, an upward one brings it back (#53).
|
||||
* lists the month's agenda or the selected day — and a downward drag trades the
|
||||
* pane away for the full paged grid, an upward one brings it back (#53). The
|
||||
* paged style is the same surface opened the other way up (#111).
|
||||
*
|
||||
* The grid slides between months like the paged style, and stands only as many
|
||||
* rows tall as its own month spans (#162). A swipe between a five-row month and
|
||||
* a six-row one therefore moves the pane by a row as well as swapping the grid;
|
||||
* the row it hands back is worth more to the pane than a still edge is.
|
||||
* One pager holds whole months — grid, handle and pane — and each page switches
|
||||
* between its two faces itself. An expand therefore morphs inside a page that
|
||||
* stays put: with a pager per face, every expand swapped one pager for another,
|
||||
* and whether the pieces paired up came down to frame timing.
|
||||
*
|
||||
* The grid stands only as many rows tall as its own month spans (#162).
|
||||
*
|
||||
* Expansion is deliberately **not** a stored preference. It is a way to look at
|
||||
* the month you are on, not a fourth style; persisted, someone would expand it
|
||||
@@ -1350,53 +1349,90 @@ private fun SplitMonthContent(
|
||||
}
|
||||
},
|
||||
)
|
||||
// In the split style a tap in the expanded grid picks the day and drops back,
|
||||
// which gives the expanded month a job — a chooser you dip into — rather than
|
||||
// a mode you can get stranded in. The collapse then runs with the selection
|
||||
// already set, so the pane arrives showing the day you picked. The paged
|
||||
// style's grid is its home, so a tap there opens the day (#111).
|
||||
val onPickDay: (LocalDate) -> Unit = if (initiallyExpanded) {
|
||||
onOpenDay
|
||||
} else {
|
||||
{
|
||||
onSelectDay(it)
|
||||
onExpandedChange(false)
|
||||
}
|
||||
}
|
||||
// The view switch's own shared scope: a SharedTransitionLayout nested in the
|
||||
// host's would be a second lookahead scope. Null under reduced motion.
|
||||
val viewMorph = LocalViewMorph.current
|
||||
val shared = viewMorph?.shared
|
||||
val chipDrag = LocalMonthDrag.current
|
||||
|
||||
when (state) {
|
||||
MonthUiState.Loading -> MonthGridLoading()
|
||||
is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||
is MonthUiState.Success -> SharedTransitionLayout(Modifier.fillMaxSize()) {
|
||||
is MonthUiState.Success -> HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
// The months either side are composed ahead, so a swipe lands on a
|
||||
// loaded month rather than watching it fill in.
|
||||
beyondViewportPageCount = 1,
|
||||
verticalAlignment = Alignment.Top,
|
||||
// A held chip moves within its month; the page stays put under it.
|
||||
userScrollEnabled = chipDrag?.isDragging != true,
|
||||
flingBehavior = PagerDefaults.flingBehavior(
|
||||
state = pagerState,
|
||||
snapAnimationSpec = rememberCalendarPageSpec(),
|
||||
),
|
||||
) { index ->
|
||||
val ym = yearMonthForIndex(index)
|
||||
val pageState by remember(ym, weekStart) { monthState(ym, weekStart) }
|
||||
.collectAsStateWithLifecycle()
|
||||
val page = pageState as? MonthUiState.Success ?: return@HorizontalPager
|
||||
// Only the settled page carries the drag and the morph tags: its
|
||||
// neighbours repeat some of its dates and would claim them.
|
||||
val active = index == pagerState.settledPage
|
||||
// The selection only ever lies in the anchor month.
|
||||
val pageSelected = selected?.takeIf { it.year == ym.year && it.month == ym.month }
|
||||
AnimatedContent(
|
||||
targetState = expanded,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
// Both branches fill the same box — the grid grows into exactly the
|
||||
// Both faces fill the same page — the grid grows into exactly the
|
||||
// room the pane gives up — so there is no size change to contain.
|
||||
// The travel between them is the shared elements' job, not a slide.
|
||||
// The travel between them is the shared elements' job.
|
||||
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
|
||||
label = "split-expand-transition",
|
||||
label = "split-expand",
|
||||
) { isExpanded ->
|
||||
// Only the incoming face drives the pager; the outgoing one draws
|
||||
// its month still for the length of the cross-fade.
|
||||
val leaving = transition.targetState == EnterExitState.PostExit
|
||||
CompositionLocalProvider(
|
||||
// The split style morphs within itself and so sits the view
|
||||
// switch's morph out (#184): one element can't travel in two
|
||||
// shared transitions at once.
|
||||
LocalViewMorph provides null,
|
||||
// Null under reduced motion: nothing is tagged, nothing
|
||||
// travels, and the cross-fade above is the whole transition.
|
||||
LocalMonthMorph provides if (reduceMotion) {
|
||||
null
|
||||
// The view switch's morph stays off a face until it has fully
|
||||
// entered (#184): both faces tag the same events for it. Switched
|
||||
// off rather than removed — adding and dropping the tags rebuilt
|
||||
// every bar's modifier chain around the expand morph, which left
|
||||
// bars standing still on later collapses.
|
||||
val faceTransition = transition
|
||||
val faceViewMorph = remember(viewMorph, active, faceTransition) {
|
||||
viewMorph?.takeIf { active }?.gated {
|
||||
faceTransition.currentState == EnterExitState.Visible
|
||||
}
|
||||
}
|
||||
val monthMorph = remember(active, reduceMotion, shared, this) {
|
||||
if (active && !reduceMotion && shared != null) {
|
||||
MonthMorphScope(shared, this)
|
||||
} else {
|
||||
MonthMorphScope(this@SharedTransitionLayout, this@AnimatedContent)
|
||||
},
|
||||
null
|
||||
}
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalMonthDrag provides chipDrag.takeIf { active },
|
||||
LocalViewMorph provides faceViewMorph,
|
||||
LocalMonthMorph provides monthMorph,
|
||||
) {
|
||||
val months = SplitMonths(pagerState, weekStart, monthState, static = leaving)
|
||||
if (isExpanded) {
|
||||
SplitMonthExpanded(
|
||||
months = months,
|
||||
selected = selected,
|
||||
state = page,
|
||||
selected = pageSelected,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
expandDrag = expandDrag,
|
||||
// A tap in the expanded grid picks the day and drops
|
||||
// back, which gives the expanded month a job — a
|
||||
// chooser you dip into — rather than a mode you can
|
||||
// get stranded in. The collapse then runs with the
|
||||
// selection already set, so the pane arrives showing
|
||||
// the day you picked.
|
||||
onPickDay = {
|
||||
onSelectDay(it)
|
||||
onExpandedChange(false)
|
||||
},
|
||||
onPickDay = onPickDay,
|
||||
// A tapped chip is asking for that event, not for its
|
||||
// day, so it opens the detail from here too rather
|
||||
// than collapsing (#187).
|
||||
@@ -1404,10 +1440,22 @@ private fun SplitMonthContent(
|
||||
onCollapse = { onExpandedChange(false) },
|
||||
)
|
||||
} else {
|
||||
// The pane is still invisible for the first stretch of a
|
||||
// collapse, so it is built once the grid has mostly
|
||||
// landed, not on the morph's opening frames.
|
||||
var paneReady by remember {
|
||||
mutableStateOf(reduceMotion || transition.currentState == EnterExitState.Visible)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
if (!paneReady) {
|
||||
delay(SPLIT_PANE_COMPOSE_DELAY_MILLIS)
|
||||
paneReady = true
|
||||
}
|
||||
}
|
||||
SplitMonthCollapsed(
|
||||
state = state,
|
||||
months = months,
|
||||
selected = selected,
|
||||
state = page,
|
||||
paneReady = paneReady,
|
||||
selected = pageSelected,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
expandDrag = expandDrag,
|
||||
onSelectDay = onSelectDay,
|
||||
@@ -1418,6 +1466,18 @@ private fun SplitMonthContent(
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
onExpand = { onExpandedChange(true) },
|
||||
// The pane waits for the grid to settle, and clears
|
||||
// out before it grows, so the two never overlap.
|
||||
paneModifier = if (reduceMotion) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.animateEnterExit(
|
||||
enter = fadeIn(
|
||||
tween(SPLIT_PANE_FADE_IN_MILLIS, SPLIT_PANE_FADE_IN_DELAY_MILLIS, SPLIT_PANE_EASING),
|
||||
),
|
||||
exit = fadeOut(tween(SPLIT_PANE_FADE_OUT_MILLIS)),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1426,19 +1486,11 @@ private fun SplitMonthContent(
|
||||
}
|
||||
}
|
||||
|
||||
/** What both faces of the split style page through — see [MonthPager]. */
|
||||
private class SplitMonths(
|
||||
val pagerState: PagerState,
|
||||
val weekStart: DayOfWeek,
|
||||
val monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
||||
val static: Boolean,
|
||||
)
|
||||
|
||||
/** The split style at rest: compact grid, handle, then the selected day's events. */
|
||||
/** The split style at rest: compact grid, handle, then the agenda or the selected day. */
|
||||
@Composable
|
||||
private fun SplitMonthCollapsed(
|
||||
state: MonthUiState.Success,
|
||||
months: SplitMonths,
|
||||
paneReady: Boolean,
|
||||
selected: LocalDate?,
|
||||
showWeekNumbers: Boolean,
|
||||
expandDrag: Modifier,
|
||||
@@ -1450,76 +1502,64 @@ private fun SplitMonthCollapsed(
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateEvent: (LocalDate) -> Unit,
|
||||
onExpand: () -> Unit,
|
||||
paneModifier: Modifier = Modifier,
|
||||
) {
|
||||
// The grid stands only as many rows tall as its month spans (#162), so a
|
||||
// swipe between a five-row and a six-row month moves the pane by a row; the
|
||||
// blend has it move with the finger rather than jump on settle.
|
||||
val gridHeights = remember { mutableStateMapOf<Int, Dp>() }
|
||||
val gridHeight = pagedHeight(months.pagerState, gridHeights, splitGridHeight(state.weeks.size))
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Grid and handle drag as one surface — the handle is what advertises the
|
||||
// gesture, so it has to answer to it as well as to a tap. The pane is
|
||||
// outside: it scrolls, and is full of tappable rows.
|
||||
Column(expandDrag) {
|
||||
MonthPager(
|
||||
pagerState = months.pagerState,
|
||||
weekStart = months.weekStart,
|
||||
monthState = months.monthState,
|
||||
static = months.static,
|
||||
modifier = Modifier.fillMaxWidth().height(gridHeight),
|
||||
loading = {},
|
||||
) { index, page ->
|
||||
val height = splitGridHeight(page.weeks.size)
|
||||
DisposableEffect(index, height) {
|
||||
gridHeights[index] = height
|
||||
onDispose { gridHeights.remove(index) }
|
||||
}
|
||||
SplitMonthGrid(
|
||||
state = page,
|
||||
selected = selected,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onSelectDay = onSelectDay,
|
||||
)
|
||||
}
|
||||
SplitMonthGrid(
|
||||
state = state,
|
||||
selected = selected,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onSelectDay = onSelectDay,
|
||||
modifier = Modifier.height(splitGridHeight(state.weeks.size)),
|
||||
)
|
||||
SplitExpandHandle(expanded = false, onToggle = onExpand)
|
||||
}
|
||||
Box(
|
||||
Modifier.weight(1f).fillMaxWidth()
|
||||
.then(rememberSplitPaneSwipe(onPaneSwipeNext, onPaneSwipePrev)),
|
||||
) {
|
||||
if (selected == null) {
|
||||
if (!paneReady) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
} else if (selected == null) {
|
||||
// The agenda pages with the month, on the pager itself.
|
||||
Box(Modifier.weight(1f).fillMaxWidth().then(paneModifier)) {
|
||||
SplitMonthAgendaPane(
|
||||
state = state,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
} else {
|
||||
Column {
|
||||
InputChip(
|
||||
selected = true,
|
||||
onClick = onClearSelection,
|
||||
label = { Text(agendaDayLabel(selected, state.today)) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.month_split_clear_filter),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
SplitDayPane(
|
||||
date = selected,
|
||||
today = state.today,
|
||||
events = state.instancesByDay[selected],
|
||||
zone = state.zone,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// A selected day steps a day at a time instead, so the swipe is
|
||||
// taken before the pager sees it.
|
||||
Column(
|
||||
Modifier.weight(1f).fillMaxWidth()
|
||||
.then(paneModifier)
|
||||
.then(rememberSplitPaneSwipe(onPaneSwipeNext, onPaneSwipePrev)),
|
||||
) {
|
||||
InputChip(
|
||||
selected = true,
|
||||
onClick = onClearSelection,
|
||||
label = { Text(agendaDayLabel(selected, state.today)) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.month_split_clear_filter),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
SplitDayPane(
|
||||
date = selected,
|
||||
today = state.today,
|
||||
events = state.instancesByDay[selected],
|
||||
zone = state.zone,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1535,7 +1575,8 @@ private fun rememberSplitPaneSwipe(onNext: () -> Unit, onPrev: () -> Unit): Modi
|
||||
var fired = false
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { distance = 0f; fired = false },
|
||||
onHorizontalDrag = { _, drag ->
|
||||
onHorizontalDrag = { change, drag ->
|
||||
change.consume()
|
||||
distance += drag
|
||||
if (!fired && kotlin.math.abs(distance) >= threshold) {
|
||||
fired = true
|
||||
@@ -1557,7 +1598,7 @@ private fun rememberSplitPaneSwipe(onNext: () -> Unit, onPrev: () -> Unit): Modi
|
||||
*/
|
||||
@Composable
|
||||
private fun SplitMonthExpanded(
|
||||
months: SplitMonths,
|
||||
state: MonthUiState.Success,
|
||||
/** Marked nowhere here; it only anchors the outline's morph. */
|
||||
selected: LocalDate?,
|
||||
showWeekNumbers: Boolean,
|
||||
@@ -1569,15 +1610,9 @@ private fun SplitMonthExpanded(
|
||||
// The whole screen drags here, handle included — there is no pane to keep out
|
||||
// of it, and the handle is the obvious thing to reach for on the way back.
|
||||
Column(Modifier.fillMaxSize().then(expandDrag)) {
|
||||
MonthPager(
|
||||
pagerState = months.pagerState,
|
||||
weekStart = months.weekStart,
|
||||
monthState = months.monthState,
|
||||
static = months.static,
|
||||
modifier = Modifier.weight(1f),
|
||||
) { _, page ->
|
||||
Box(Modifier.weight(1f)) {
|
||||
MonthGrid(
|
||||
state = page,
|
||||
state = state,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onPickDay,
|
||||
onEventClick = onEventClick,
|
||||
@@ -1655,6 +1690,13 @@ private val SPLIT_HANDLE_WIDTH = 32.dp
|
||||
private val SPLIT_HANDLE_HEIGHT = 4.dp
|
||||
private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp
|
||||
|
||||
/** The pane below the split grid fades in once the grid's morph has mostly landed. */
|
||||
private const val SPLIT_PANE_FADE_IN_DELAY_MILLIS = 300
|
||||
private const val SPLIT_PANE_COMPOSE_DELAY_MILLIS = 250L
|
||||
private const val SPLIT_PANE_FADE_IN_MILLIS = 250
|
||||
private const val SPLIT_PANE_FADE_OUT_MILLIS = 100
|
||||
private val SPLIT_PANE_EASING = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
|
||||
|
||||
/**
|
||||
* The split style's grid (#53): the month compressed to day numbers and event
|
||||
* dots, with the selected day listed underneath by [SplitDayPane].
|
||||
@@ -1756,7 +1798,7 @@ private fun SplitDayCell(
|
||||
// the whole cell, which read as a second state on top of the day rather than
|
||||
// as a mark on it — and fought today's own circle when they coincided.
|
||||
val background = when {
|
||||
isToday && inMonth -> MaterialTheme.colorScheme.primaryContainer
|
||||
isToday && inMonth -> todayCellColor()
|
||||
inMonth -> MaterialTheme.colorScheme.surfaceContainer
|
||||
else -> MaterialTheme.colorScheme.surfaceContainerLow
|
||||
}
|
||||
@@ -1953,7 +1995,7 @@ private fun SplitMonthAgendaPane(
|
||||
zone = state.zone,
|
||||
position = positionOf(index, events.size),
|
||||
dimmed = dimCutoff != null && event.hasEnded(dimCutoff),
|
||||
modifier = animateItemMotion(),
|
||||
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day)),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
}
|
||||
@@ -2026,7 +2068,7 @@ internal fun SplitDayPane(
|
||||
// An event added to or removed from the day you are
|
||||
// already looking at moves its neighbours rather
|
||||
// than teleporting them.
|
||||
modifier = animateItemMotion(),
|
||||
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2317,7 +2359,7 @@ private fun MonthWeekRow(
|
||||
.todayRipple(todayCol?.let { col - it }) { ripple.value }
|
||||
.background(
|
||||
color = when {
|
||||
d == today && inMonth(d) -> MaterialTheme.colorScheme.primaryContainer
|
||||
d == today && inMonth(d) -> todayCellColor()
|
||||
inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer
|
||||
// A blanked cell draws nothing at all: it is
|
||||
// the gap that shows where the month starts.
|
||||
|
||||
@@ -112,6 +112,7 @@ import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberBlockTimeRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics
|
||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
@@ -1436,6 +1437,11 @@ private fun EventBlock(
|
||||
val showTime = block.laneCount == 1 &&
|
||||
metrics.available >= metrics.titleLine + metrics.timeLine
|
||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||
val shownTime = rememberBlockTimeRange(
|
||||
minToHm(block.startMin, use24Hour, locale),
|
||||
minToHm(block.endMin, use24Hour, locale),
|
||||
textWidth,
|
||||
)
|
||||
// A short block drops the title rather than serving a horizontally sliced
|
||||
// one: half a letter reads as a rendering fault, while a bare colour chip
|
||||
// reads as what it is — an event too brief to label. Tap still opens it, and
|
||||
@@ -1461,7 +1467,7 @@ private fun EventBlock(
|
||||
)
|
||||
val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
|
||||
if (showTime) metrics.timeLine else 0.dp
|
||||
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
|
||||
val timeMaxLines = if (showTime) blockTimeLines(shownTime, textWidth, spare) else 1
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
@@ -1520,7 +1526,7 @@ private fun EventBlock(
|
||||
}
|
||||
if (showTime) {
|
||||
BlockTimeLabel(
|
||||
label = timeLabel,
|
||||
label = shownTime,
|
||||
color = paint.secondaryInk,
|
||||
maxLines = timeMaxLines,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
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 |
@@ -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.
|
||||
@@ -1,2 +0,0 @@
|
||||
Fixed
|
||||
• A custom Agenda range no longer crashes the app in French. Choosing a custom range — or just typing a number into that field — closed the app on any phone set to French, and only a range of exactly one day escaped it. Other languages were never affected.
|
||||
@@ -1,20 +1,11 @@
|
||||
Calendula is a modern, open-source calendar app for Android. It works
|
||||
directly on the system calendar provider, so any source synced to your
|
||||
device — Nextcloud via DAVx5, Google, local, WebCal subscriptions — shows up
|
||||
automatically, and changes you make sync back the same way.
|
||||
Calendula is an open-source calendar app for Android. It works directly on the system calendar provider, so anything synced to your device (Nextcloud via DAVx5, Google, local calendars, WebCal subscriptions) shows up in Calendula, and changes you make sync back the same way.
|
||||
|
||||
Create, edit and delete events, including recurring events with scoped
|
||||
changes (only this event / this and all following / the whole series) and a
|
||||
simple repeat picker. Calendula also delivers your event reminders as
|
||||
notifications — tap one and you're on the event.
|
||||
It has month, week, day and agenda views. You can create, edit and delete events, including recurring ones, and set repeats from presets or your own rules. When you change a recurring event, you choose whether the change applies to that one event, to it and all following ones, or to the whole series.
|
||||
|
||||
Optionally, show your contacts' birthdays, anniversaries and other special
|
||||
dates in your calendar. You turn this on yourself; contacts are read only on
|
||||
your device, are never uploaded, and are never changed — Calendula only
|
||||
mirrors the dates into local calendars you fully control.
|
||||
Calendula shows your event reminders as notifications. Tap one to open the event.
|
||||
|
||||
The differentiator is the design: real Material 3 Expressive throughout,
|
||||
with dynamic color, expressive motion, and expressive shapes.
|
||||
If you turn it on, Calendula also shows your contacts' birthdays, anniversaries and other dates. Contacts are read on your device and are never uploaded or changed. Calendula copies the dates into local calendars that you control.
|
||||
|
||||
Privacy: zero telemetry, no analytics, no network access — your data never
|
||||
leaves the device.
|
||||
The design is Material 3 Expressive throughout, with dynamic color and expressive motion and shapes.
|
||||
|
||||
Calendula has no telemetry, no analytics and no network access. Your data never leaves the device.
|
||||
|
||||
|
After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 403 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 338 KiB |
|
Before Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 406 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 96 KiB |
@@ -1 +1 @@
|
||||
A modern Material 3 Expressive calendar for Android.
|
||||
A Material 3 Expressive calendar for Android with no network access.
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
Calendula es una aplicación de calendario moderna y de código abierto
|
||||
para Android. Funciona directamente sobre el proveedor de calendario del
|
||||
sistema: cualquier origen sincronizado en tu dispositivo — Nextcloud
|
||||
mediante DAVx5, Google, calendarios locales, suscripciones WebCal —
|
||||
aparece automáticamente, y los cambios que hagas se sincronizan de vuelta
|
||||
del mismo modo.
|
||||
Calendula es una aplicación de calendario de código abierto para Android. Funciona directamente sobre el proveedor de calendario del sistema, así que todo lo que se sincroniza con tu dispositivo (Nextcloud mediante DAVx5, Google, calendarios locales, suscripciones WebCal) aparece en Calendula, y los cambios que hagas se sincronizan de vuelta por el mismo camino.
|
||||
|
||||
Crea, edita y elimina eventos, incluidos los recurrentes con cambios
|
||||
acotados (solo este evento / este y todos los siguientes / toda la serie)
|
||||
y un sencillo selector de repetición. Calendula también entrega los
|
||||
recordatorios de tus eventos como notificaciones: toca una y estarás en
|
||||
el evento.
|
||||
Tiene vistas de mes, semana, día y agenda. Puedes crear, editar y eliminar eventos, también los periódicos, y definir repeticiones con ajustes predefinidos o reglas propias. Al cambiar un evento periódico, eliges si el cambio se aplica solo a ese evento, a ese y todos los siguientes, o a toda la serie.
|
||||
|
||||
Si quieres, muestra en tu calendario los cumpleaños, aniversarios y otras
|
||||
fechas especiales de tus contactos. Tú mismo lo activas; los contactos se
|
||||
leen solo en tu dispositivo, nunca se suben y nunca se modifican —
|
||||
Calendula solo refleja las fechas en calendarios locales que controlas
|
||||
por completo.
|
||||
Calendula muestra los recordatorios de tus eventos como notificaciones. Toca uno para abrir el evento.
|
||||
|
||||
El factor diferenciador es el diseño: auténtico Material 3 Expressive en
|
||||
toda la aplicación, con Dynamic Color, movimiento expresivo y formas
|
||||
expresivas.
|
||||
Si lo activas, Calendula muestra también los cumpleaños, aniversarios y otras fechas de tus contactos. Los contactos se leen en tu dispositivo y nunca se suben ni se modifican. Calendula copia las fechas en calendarios locales que tú controlas.
|
||||
|
||||
Privacidad: cero telemetría, sin analíticas, sin acceso a la red — tus
|
||||
datos nunca salen del dispositivo.
|
||||
El diseño es Material 3 Expressive en todo momento, con color dinámico y movimiento y formas expresivos.
|
||||
|
||||
Calendula no tiene telemetría, ni analíticas, ni acceso a la red. Tus datos nunca salen del dispositivo.
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 207 KiB |
@@ -1 +1 @@
|
||||
Un calendario moderno con Material 3 Expressive para Android.
|
||||
Un calendario Material 3 Expressive para Android sin acceso a la red.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
Calendula est une application de calendrier open source pour Android. Elle fonctionne directement sur le fournisseur de calendrier du système : tout ce qui est synchronisé sur votre appareil (Nextcloud via DAVx5, Google, calendriers locaux, abonnements WebCal) apparaît dans Calendula, et vos modifications sont synchronisées en retour de la même façon.
|
||||
|
||||
Elle propose des vues mois, semaine, jour et agenda. Vous pouvez créer, modifier et supprimer des événements, y compris récurrents, et définir les répétitions à partir de modèles ou de vos propres règles. Quand vous modifiez un événement récurrent, vous choisissez si la modification s'applique à cet événement seul, à celui-ci et aux suivants, ou à toute la série.
|
||||
|
||||
Calendula affiche les rappels de vos événements sous forme de notifications. Touchez-en une pour ouvrir l'événement.
|
||||
|
||||
Si vous l'activez, Calendula affiche aussi les anniversaires, fêtes et autres dates de vos contacts. Les contacts sont lus sur votre appareil et ne sont jamais envoyés ni modifiés. Calendula copie les dates dans des calendriers locaux que vous contrôlez.
|
||||
|
||||
Le design suit Material 3 Expressive partout, avec des couleurs dynamiques et des animations et formes expressives.
|
||||
|
||||
Calendula n'a ni télémétrie, ni statistiques d'usage, ni accès au réseau. Vos données ne quittent jamais l'appareil.
|
||||
@@ -0,0 +1 @@
|
||||
Un calendrier Material 3 Expressive pour Android, sans accès au réseau.
|
||||
@@ -0,0 +1 @@
|
||||
Calendula
|
||||
@@ -0,0 +1,11 @@
|
||||
A Calendula nyílt forráskódú naptáralkalmazás Androidra. Közvetlenül a rendszer naptárszolgáltatójával dolgozik, így minden, ami az eszközödre szinkronizálódik (Nextcloud a DAVx5-ön keresztül, Google, helyi naptárak, WebCal-előfizetések), megjelenik a Calendulában, a módosításaid pedig ugyanígy szinkronizálódnak vissza.
|
||||
|
||||
Van havi, heti, napi és napirend nézete. Eseményeket hozhatsz létre, szerkeszthetsz és törölhetsz, ismétlődőket is, az ismétlést pedig sablonból vagy saját szabállyal állíthatod be. Ismétlődő esemény módosításakor kiválasztod, hogy a változás csak erre az eseményre, erre és az összes következőre, vagy a teljes sorozatra vonatkozzon.
|
||||
|
||||
A Calendula értesítésként jeleníti meg az események emlékeztetőit. Koppints egyre, és megnyílik az esemény.
|
||||
|
||||
Ha bekapcsolod, a Calendula a névjegyeid születésnapjait, évfordulóit és más dátumait is megmutatja. A névjegyeket az eszközön olvassa, soha nem tölti fel és nem módosítja őket. A Calendula a dátumokat az általad kezelt helyi naptárakba másolja.
|
||||
|
||||
A dizájn mindenhol Material 3 Expressive, dinamikus színekkel, kifejező mozgással és formákkal.
|
||||
|
||||
A Calendulában nincs telemetria, analitika és hálózati hozzáférés. Az adataid soha nem hagyják el az eszközt.
|
||||
@@ -0,0 +1 @@
|
||||
Material 3 Expressive naptár Androidra, hálózati hozzáférés nélkül.
|
||||
@@ -0,0 +1 @@
|
||||
Calendula
|
||||
@@ -1,24 +1,11 @@
|
||||
Calendula è un'app di calendario moderna e open source per Android.
|
||||
Funziona direttamente sul provider di calendario di sistema: qualsiasi
|
||||
origine sincronizzata sul tuo dispositivo — Nextcloud tramite DAVx5,
|
||||
Google, calendari locali, iscrizioni WebCal — compare automaticamente, e
|
||||
le modifiche che apporti vengono sincronizzate a ritroso allo stesso
|
||||
modo.
|
||||
Calendula è un'app di calendario open source per Android. Funziona direttamente sul provider di calendario di sistema, quindi tutto ciò che è sincronizzato sul tuo dispositivo (Nextcloud tramite DAVx5, Google, calendari locali, iscrizioni WebCal) compare in Calendula, e le modifiche che fai vengono sincronizzate allo stesso modo.
|
||||
|
||||
Crea, modifica ed elimina eventi, compresi quelli ricorrenti con
|
||||
modifiche mirate (solo questo evento / questo e tutti i successivi /
|
||||
l'intera serie) e un semplice selettore di ripetizione. Calendula
|
||||
recapita anche i promemoria dei tuoi eventi come notifiche: ne tocchi una
|
||||
e sei subito sull'evento.
|
||||
Ha le viste mese, settimana, giorno e agenda. Puoi creare, modificare ed eliminare eventi, anche ricorrenti, e impostare le ripetizioni da modelli o regole personalizzate. Quando modifichi un evento ricorrente, scegli se la modifica vale solo per quell'evento, per quello e tutti i successivi o per l'intera serie.
|
||||
|
||||
Se vuoi, mostra nel calendario i compleanni, gli anniversari e le altre
|
||||
date speciali dei tuoi contatti. Sei tu ad attivarlo; i contatti vengono
|
||||
letti solo sul tuo dispositivo, non vengono mai caricati e non vengono
|
||||
mai modificati — Calendula si limita a rispecchiare le date in calendari
|
||||
locali che controlli completamente.
|
||||
Calendula mostra i promemoria degli eventi come notifiche. Toccane una per aprire l'evento.
|
||||
|
||||
La differenza sta nel design: vero Material 3 Expressive ovunque, con
|
||||
Dynamic Color, animazioni espressive e forme espressive.
|
||||
Se lo attivi, Calendula mostra anche compleanni, anniversari e altre date dei tuoi contatti. I contatti vengono letti sul dispositivo e non vengono mai caricati né modificati. Calendula copia le date in calendari locali che controlli tu.
|
||||
|
||||
Privacy: zero telemetria, nessuna analisi, nessun accesso alla rete — i
|
||||
tuoi dati non lasciano mai il dispositivo.
|
||||
Il design è Material 3 Expressive in ogni schermata, con colori dinamici e animazioni e forme espressive.
|
||||
|
||||
Calendula non ha telemetria, analisi o accesso alla rete. I tuoi dati non lasciano mai il dispositivo.
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 99 KiB |