Compare commits

...
11 Commits
Author SHA1 Message Date
Jean-Luc Makiolaandmakiolaj 6afcfd49f1 Release v2.22.5 (#381)
Renovate / renovate (push) Successful in 1m13s
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 19m27s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m2s
When you reopened the app the next morning, the month view still marked yesterday as today and still had the day you picked the evening before selected.

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

Closes #373

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

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

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

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

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

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

Closes #363
Closes #364

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

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

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

### Why

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

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

### Checklist

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

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/369
2026-10-06 18:43:45 +02:00
renovate-bot 4342c6ab6d fix(deps): update composebom to v2026.09.00 (#238)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m6s
This PR contains the following updates:

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

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/238
2026-10-05 13:40:00 +02:00
renovate-bot 57bad5c74c fix(deps): update material3 (alpha) to v1.5.0-alpha29 (#274)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Canceled after 0s
This PR contains the following updates:

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

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/274
2026-10-05 13:39:41 +02:00
renovate-bot ebf833d0bd fix(deps): update glance to v1.2.0 (#275)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m10s
This PR contains the following updates:

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

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/275
2026-10-05 13:39:03 +02:00
renovate-bot 2f24a9878c fix(deps): update lifecyclecompose to v2.11.0 (#277)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m5s
This PR contains the following updates:

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

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

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

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

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

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

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

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

Closes #359
2026-10-02 20:41:17 +02:00
135 changed files with 1471 additions and 508 deletions
+16 -1
View File
@@ -37,9 +37,24 @@ jobs:
- name: Reproducible-release invariant - name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh run: bash scripts/check_reproducible_release.sh
# versionCode must match versionName (the official F-Droid repo builds the
# tag as committed), and a beta must never reach main.
- name: Committed version is well-formed
env:
BASE: ${{ github.base_ref }}
run: |
set -e
bash scripts/version_info.sh --check
if [ "${BASE#refs/heads/}" = "main" ] && [ "$(bash scripts/version_info.sh channel)" = "beta" ]; then
echo "ERROR: versionName $(bash scripts/version_info.sh version) is a beta." >&2
echo "Set the stable version (and its versionCode) before merging into main." >&2
exit 1
fi
# Play rejects a "What's New" over 500 characters, which would fail the # Play rejects a "What's New" over 500 characters, which would fail the
# upload after the release had already shipped everywhere else. Cheap, so # upload after the release had already shipped everywhere else. Cheap, so
# it runs on every PR rather than only on the release merge. # it runs on every PR rather than only on the release merge. A beta ships
# no What's New, so only the older files are checked for one.
- name: Changelog length invariant - name: Changelog length invariant
run: bash scripts/check_changelog_lengths.sh run: bash scripts/check_changelog_lengths.sh
+57
View File
@@ -0,0 +1,57 @@
name: Android build environment
description: JDK 17, Android SDK + build tools, Gradle cache and jq for the release and beta pipelines.
runs:
using: composite
steps:
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: ''
- name: Setup Android SDK cache
uses: actions/cache@v4
with:
path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages
shell: bash
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
"platform-tools" \
"platforms;android-37.0" \
"build-tools;36.0.0"
- name: Setup Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Install jq
shell: bash
run: |
set -e
SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update
$SUDO apt-get install -y jq
elif command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache jq
fi
- name: Grant execute permission for gradlew
shell: bash
run: chmod +x ./gradlew
+97
View File
@@ -0,0 +1,97 @@
name: Beta — Codeberg pre-release
# Pushing a release/* branch whose versionName is X.Y.Z-beta.N publishes a
# Codeberg pre-release (APK + SHA-256) and a Gitea pre-release (R8 mapping).
# Betas never reach F-Droid or Play. See docs/RELEASING.md.
on:
push:
branches: ['release/**']
concurrency:
group: beta
cancel-in-progress: false
jobs:
detect:
# Gitea only; see the same guard in release.yaml.
if: github.repository_owner == 'makiolaj'
runs-on: docker
outputs:
is_beta: ${{ steps.v.outputs.is_beta }}
version: ${{ steps.v.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve version and whether it still needs publishing
id: v
run: |
set -e
INFO=$(bash scripts/version_info.sh)
echo "$INFO"
echo "$INFO" >> "$GITHUB_OUTPUT"
if [ "$(bash scripts/version_info.sh channel)" != beta ]; then
echo "Not a beta — nothing to do."
echo "is_beta=false" >> "$GITHUB_OUTPUT"
exit 0
fi
GATE=$(bash scripts/release_gate.sh)
echo "is_beta=${GATE#cut=}" >> "$GITHUB_OUTPUT"
beta:
needs: detect
if: needs.detect.outputs.is_beta == 'true'
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
VERSION: ${{ needs.detect.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Android build environment
uses: ./.gitea/actions/android-env
- name: Pin versionCode to versionName
run: bash scripts/version_info.sh --pin
- name: Unit tests
run: ./gradlew testDebugUnitTest
# The real app key, so a beta updates in place to later betas and stable.
- name: Setup Android Keystore
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
run: bash scripts/write_keystore.sh
- name: Build release APK
run: ./gradlew assembleRelease
- name: Create tag + Gitea pre-release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
SHA: ${{ github.sha }}
run: |
set -e
bash scripts/release_notes.sh "$VERSION" > release-notes.md
cat release-notes.md
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/release/mapping.txt \
bash scripts/publish_gitea_release.sh
- name: Publish pre-release to Codeberg
env:
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
SHA: ${{ github.sha }}
run: |
set -e
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/release/app-release.apk \
bash scripts/publish_codeberg_release.sh
+32 -255
View File
@@ -7,7 +7,9 @@ name: Release — F-Droid repo + Gitea/Codeberg release + Play
# that release to Codeberg with the signed APK + a SHA-256 checksum as a # that release to Codeberg with the signed APK + a SHA-256 checksum as a
# direct-download channel — the tag is an output of the pipeline, not its # direct-download channel — the tag is an output of the pipeline, not its
# trigger. Ordinary merges (no version bump) fall through `detect` and do # trigger. Ordinary merges (no version bump) fall through `detect` and do
# nothing. # nothing. Betas (X.Y.Z-beta.N) never come through here: beta.yaml cuts them
# from release/* branches as Codeberg-only pre-releases, and `detect` refuses
# one that reaches main.
# #
# A trailing `play` job then uploads the App Bundle to Google Play. It is last # A trailing `play` job then uploads the App Bundle to Google Play. It is last
# and separate because Play is the only channel that can reject a good build for # and separate because Play is the only channel that can reject a good build for
@@ -54,56 +56,28 @@ jobs:
- name: Resolve version and whether it is a new release - name: Resolve version and whether it is a new release
id: v id: v
env:
# Tags are read from Codeberg, which is canonical — deliberately NOT
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
# source does not have. A tag minted here on Gitea is therefore wiped
# by the next sync (Codeberg does not have it yet) and only reappears
# once the tag push at the end of this workflow propagates back.
# Asking Gitea inside that window would report "no tag" for a release
# that already shipped, and cut it a second time.
# Public repo, so this read needs no token.
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
run: | run: |
set -e set -e
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts) INFO=$(bash scripts/version_info.sh)
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi echo "$INFO"
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3) echo "$INFO" >> "$GITHUB_OUTPUT"
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0} VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) CHANNEL=$(echo "$INFO" | sed -n 's/^channel=//p')
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
echo "Resolved version $VERSION (code $VERSION_CODE)"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "Manual dispatch — re-sign path, not a release." echo "Manual dispatch — re-sign path, not a release."
echo "is_release=false" >> "$GITHUB_OUTPUT" echo "is_release=false" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi
# A tag for this version already existing means the release shipped on # Backstop for CI's guard: betas ship from release/* via beta.yaml.
# an earlier push; do nothing. Absent => this merge cuts the release. if [ "$CHANNEL" != "stable" ]; then
# echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2
# Anything other than a clean 200/404 is treated as fatal rather than exit 1
# as "no tag". A Codeberg outage or a network blip would otherwise fi
# read as absent and re-cut a release that has already shipped — # Tags are read from Codeberg, the canonical forge: a tag minted here is
# republishing to F-Droid and Play. Failing here is recoverable; a # wiped by the next mirror sync until Codeberg has it. A lookup error is
# duplicate release is not. # fatal, since guessing "no tag" would re-cut a shipped release.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000) GATE=$(bash scripts/release_gate.sh)
case "$STATUS" in echo "is_release=${GATE#cut=}" >> "$GITHUB_OUTPUT"
200)
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
;;
404)
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
echo "is_release=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
exit 1
;;
esac
# Releases: build + sign + publish, then mint the tag and Gitea release. # Releases: build + sign + publish, then mint the tag and Gitea release.
# Also runs on manual dispatch, where it skips the build and just re-signs and # Also runs on manual dispatch, where it skips the build and just re-signs and
@@ -124,65 +98,13 @@ jobs:
with: with:
submodules: recursive submodules: recursive
- name: Setup Java - name: Android build environment
uses: actions/setup-java@v4 uses: ./.gitea/actions/android-env
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Android SDK # The committed versionName is the source of truth; pin the derived code.
uses: android-actions/setup-android@v3
with:
packages: ''
- name: Setup Android SDK cache
uses: actions/cache@v4
with:
path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
"platform-tools" \
"platforms;android-37.0" \
"build-tools;36.0.0"
- name: Setup Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Install jq
run: |
set -e
SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update
$SUDO apt-get install -y jq
elif command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache jq
fi
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
# The committed versionName is the source of truth. Pin versionCode to the
# value derived from it so the published APK's code is always
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
- name: Pin versionCode to versionName - name: Pin versionCode to versionName
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
run: | run: bash scripts/version_info.sh --pin
set -e
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
grep -E 'versionName|versionCode' app/build.gradle.kts
# Test the exact commit being shipped (only on a real release). # Test the exact commit being shipped (only on a real release).
- name: Unit tests - name: Unit tests
@@ -195,15 +117,7 @@ jobs:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }} KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
run: | run: bash scripts/write_keystore.sh
mkdir -p app
echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks
cat > key.properties <<EOF
storePassword=$KEY_PASSWORD
keyPassword=$KEY_PASSWORD
keyAlias=$KEY_ALIAS
storeFile=upload-keystore.jks
EOF
- name: Build release APK - name: Build release APK
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
@@ -312,6 +226,7 @@ jobs:
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at # Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
# this commit, so the tag only ever marks a fully-shipped release (and a # this commit, so the tag only ever marks a fully-shipped release (and a
# failure before here leaves no tag, so re-running the workflow retries). # failure before here leaves no tag, so re-running the workflow retries).
# Also attaches the R8 mapping, best-effort.
- name: Create tag + Gitea release - name: Create tag + Gitea release
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
env: env:
@@ -320,74 +235,10 @@ jobs:
SHA: ${{ github.sha }} SHA: ${{ github.sha }}
run: | run: |
set -e set -e
TAG="v$VERSION" bash scripts/release_notes.sh "$VERSION" > release-notes.md
# Notes = this version's CHANGELOG section. TAG="v$VERSION" PRERELEASE=false NOTES_FILE=release-notes.md \
awk -v ver="$VERSION" ' MAPPING=app/build/outputs/mapping/release/mapping.txt \
$0 ~ "^## \\[" ver "\\]" { flag = 1; next } bash scripts/publish_gitea_release.sh
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
sed -i -e '/./,$!d' release-notes.md
if [ ! -s release-notes.md ]; then
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
fi
python3 - "$TAG" "$SHA" <<'PY' > payload.json
import json, sys
print(json.dumps({
"tag_name": sys.argv[1],
"target_commitish": sys.argv[2],
"name": sys.argv[1],
"body": open("release-notes.md").read(),
"draft": False,
"prerelease": False,
}))
PY
# Upsert (re-run safe): PATCH if a release for the tag already exists,
# else POST a new one (which also creates the tag at target_commitish).
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
if [ -n "$ID" ]; then
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @payload.json "$API/releases/$ID")
OK=200
else
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @payload.json "$API/releases")
OK=201
fi
cat response.json
if [ "$CODE" != "$OK" ]; then
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
exit 1
fi
echo "Created/updated release $TAG at $SHA"
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
# Attached to the release (it's not an APK, so it fits the no-binaries
# rule). Best-effort: never fail a release over it.
- name: Attach R8 mapping to Gitea release
if: env.IS_RELEASE == 'true'
continue-on-error: true
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
MAP="app/build/outputs/mapping/release/mapping.txt"
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
TAG="v$VERSION"
ASSET="mapping-${VERSION}.txt.gz"
gzip -c "$MAP" > "/tmp/$ASSET"
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
# Replace any prior asset of the same name (re-run safe).
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$ASSET" \
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
# Mirror the release to the Codeberg mirror as a direct-download channel # Mirror the release to the Codeberg mirror as a direct-download channel
# for users who don't want F-Droid. Gitea already push-mirrors branches + # for users who don't want F-Droid. Gitea already push-mirrors branches +
@@ -406,84 +257,10 @@ jobs:
SHA: ${{ github.sha }} SHA: ${{ github.sha }}
run: | run: |
set -e set -e
if [ -z "${TOKEN:-}" ]; then [ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish." TAG="v$VERSION" PRERELEASE=false NOTES_FILE=release-notes.md \
exit 0 APK=app/build/outputs/apk/release/app-release.apk \
fi bash scripts/publish_codeberg_release.sh
TAG="v$VERSION"
APK="app/build/outputs/apk/release/app-release.apk"
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
ASSET_APK="calendula_v${VERSION}.apk"
ASSET_SUM="${ASSET_APK}.sha256"
cp "$APK" "/tmp/$ASSET_APK"
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
# Release notes: reuse the section extracted for the Gitea release,
# fall back to the CHANGELOG entry if that step's file is gone.
if [ ! -s release-notes.md ]; then
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
sed -i -e '/./,$!d' release-notes.md
fi
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
# The pipeline creates the tag via the Gitea API, which the push mirror
# (sync_on_commit only fires on real git pushes) doesn't propagate
# promptly — so a release POST that carries a target_commitish can
# outrun the mirror and 500 on a commit/tag Codeberg hasn't received.
# Push the tag straight to Codeberg so it's guaranteed present, then
# attach the release to that existing tag with NO target_commitish
# (which is what triggered the 500).
git tag -f "$TAG" "$SHA"
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/calendula.git" \
"refs/tags/$TAG"
python3 - "$TAG" <<'PY' > cb-payload.json
import json, sys
print(json.dumps({
"tag_name": sys.argv[1],
"name": sys.argv[1],
"body": open("release-notes.md").read(),
"draft": False,
"prerelease": False,
}))
PY
# Create (or update) the release. Codeberg 500s on a POST/GET against a
# tag it has only just received — the release request outruns the
# indexing of the ref we pushed a moment ago — so a single attempt kept
# failing and skipping the mirror even though the very same call
# succeeds seconds later. Retry with backoff, and PATCH in place if a
# release already exists (re-run safe). A 5xx body still exits curl 0,
# so the loop, not `set -e`, controls the flow.
ID=""
for attempt in 1 2 3 4 5 6; do
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
if [ -n "$EXIST" ]; then
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases/$EXIST"
ID="$EXIST"; break
fi
CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases")
echo "release POST attempt $attempt HTTP $CODE"
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
[ -n "$ID" ] && break
sleep $((attempt * 10))
done
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
# Attach APK + checksum, replacing any prior asset of the same name.
for A in "$ASSET_APK" "$ASSET_SUM"; do
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$A" \
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
done
echo "Published $TAG to Codeberg."
# Play takes an App Bundle, not the APK, so it is a second artifact from # Play takes an App Bundle, not the APK, so it is a second artifact from
# the same source and the same signing config — not a repackage of the # the same source and the same signing config — not a repackage of the
+11 -2
View File
@@ -1,8 +1,17 @@
name: Renovate name: Renovate
on: on:
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's # Every merge to main. Mirror syncs from Codeberg fire push events here (the
# renovate.json5 deliberately has no internal schedule (avoids double-gating). # same trigger release.yaml relies on), so a merged Renovate PR is followed
# within minutes by a run that rebases the sibling PRs it just conflicted —
# most bumps touch gradle/libs.versions.toml. Outside renovate.json5's
# `schedule` window such a run only maintains existing branches
# (updateNotScheduled), it never opens new PRs. Renovate's own rebases push
# to renovate/* branches, not main, so this cannot loop.
push:
branches: [main]
# Weekly sweep for new updates. Mondays 05:00 UTC, inside the schedule window
# in renovate.json5 — keep the two in step.
schedule: schedule:
- cron: '0 5 * * 1' - cron: '0 5 * * 1'
# Manual run for an on-demand sweep from the Actions tab. # Manual run for an on-demand sweep from the Actions tab.
+26
View File
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [2.22.5] — 2026-10-07
### Fixed
- **Reopening the app the next morning shows the right day.** The month view used to mark yesterday as today and keep the day you picked the evening before selected, so you had to tap today to see its events. Every view now moves on to the new day at midnight and when you come back to the app. A view you browsed to another date stays where it is ([#373]).
## [2.22.4] — 2026-10-07
### Fixed
- **Edits and deletions show up straight away on every phone.** On some phones, Samsung among them, an edited, moved or deleted event stayed where it was until the app was restarted, so a second edit could act on an event that had already changed. Calendula now refreshes its views itself after every change, and again when you come back to the app ([#364]).
- **The home-screen widgets do less work per update.** The month widget loads the months around the one it shows instead of more than two years of events, and the agenda widget only its chosen range. This should help widgets that stayed on "loading" on slower phones ([#363]).
## [2.22.3] — 2026-10-02
### Fixed
- **The store listing is called "Calendula: Calendar" again,** with "Calendar" in each store language. Since 2.22.0 the Play listing comes from the repository, whose title was only "Calendula". The app is unchanged ([#361]).
## [2.22.2] — 2026-10-02
### Fixed
- **2.22 reaches Google Play.** The Dutch store listing used the locale code `nl`, which Play rejects, so the Play uploads of 2.22.0 and 2.22.1 failed. It now uses `nl-NL`, and CI checks every store locale against the codes Play accepts. The app is unchanged from 2.22.1 ([#359]).
## [2.22.1] — 2026-10-02 ## [2.22.1] — 2026-10-02
### Fixed ### Fixed
@@ -1823,3 +1844,8 @@ automatically, with zero telemetry and no internet permission.
[#331]: https://codeberg.org/jlmakiola/calendula/issues/331 [#331]: https://codeberg.org/jlmakiola/calendula/issues/331
[#336]: https://codeberg.org/jlmakiola/calendula/issues/336 [#336]: https://codeberg.org/jlmakiola/calendula/issues/336
[#357]: https://codeberg.org/jlmakiola/calendula/issues/357 [#357]: https://codeberg.org/jlmakiola/calendula/issues/357
[#359]: https://codeberg.org/jlmakiola/calendula/issues/359
[#361]: https://codeberg.org/jlmakiola/calendula/issues/361
[#363]: https://codeberg.org/jlmakiola/calendula/issues/363
[#364]: https://codeberg.org/jlmakiola/calendula/issues/364
[#373]: https://codeberg.org/jlmakiola/calendula/issues/373
+2 -1
View File
@@ -136,7 +136,8 @@ For automatic updates from Codeberg, use
[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula), [add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula),
or add it by hand: *Add App*, paste `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 then *Add*. Obtainium then watches the releases and tells you when a new one
is out. is out. Betas of upcoming versions are published there too, as pre-releases; to
test them, switch on *Include prereleases* for Calendula in Obtainium.
### Google Play ### Google Play
+8 -4
View File
@@ -26,10 +26,14 @@ android {
// These committed values ARE the source of truth for a release: merging // These committed values ARE the source of truth for a release: merging
// a bumped versionName into main triggers .gitea/workflows/release.yaml, // a bumped versionName into main triggers .gitea/workflows/release.yaml,
// which builds this version and then creates the matching vX.Y.Z tag + // which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // release itself. A versionName of X.Y.Z-beta.N pushed to a release/*
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. // branch instead cuts a Codeberg-only pre-release (beta.yaml).
versionCode = 22201 // versionCode is derived from versionName by scripts/version_info.sh
versionName = "2.22.1" // (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md.
versionCode = 2220599
versionName = "2.22.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -26,6 +26,7 @@ import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
@@ -56,6 +57,7 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject
/** A prefilled create form from an external launch, with the source it came from. */ /** A prefilled create form from an external launch, with the source it came from. */
private data class InsertRequest(val form: EventForm, val source: ImportSource) private data class InsertRequest(val form: EventForm, val source: ImportSource)
@@ -63,6 +65,8 @@ private data class InsertRequest(val form: EventForm, val source: ImportSource)
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@Inject lateinit var calendarRepository: CalendarRepository
// Which of light/dark the system bars are drawn for. The styles installed // Which of light/dark the system bars are drawn for. The styles installed
// in onCreate read this field live, so androidx's config-change replay // in onCreate read this field live, so androidx's config-change replay
// picks up the in-app override instead of the night resource qualifier. // picks up the in-app override instead of the night resource qualifier.
@@ -245,6 +249,12 @@ class MainActivity : AppCompatActivity() {
) )
} }
override fun onStart() {
super.onStart()
// Changes made while we were away may never have notified us (#364).
calendarRepository.refresh()
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
// Reaching a running UI means startup succeeded; reset the loop trail. // Reaching a running UI means startup succeeded; reset the loop trail.
@@ -610,8 +610,11 @@ class AndroidCalendarDataSource @Inject constructor(
// cancelled exception for the one instance (#47). A NULL status is a // cancelled exception for the one instance (#47). A NULL status is a
// normal, un-cancelled event, so it must survive the filter — a bare // normal, un-cancelled event, so it must survive the filter — a bare
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true). // `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
"${CalendarContract.Instances.STATUS} IS NULL OR " + // DELETED = 0: a provider that soft-deletes may keep the row's
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}", // instances until a purge (#364).
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
"${CalendarContract.Events.DELETED} = 0",
null, null,
CalendarContract.Instances.BEGIN + " ASC", CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList() )?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
@@ -15,6 +15,9 @@ import kotlin.time.Instant
interface CalendarRepository { interface CalendarRepository {
fun calendars(): Flow<List<CalendarSource>> fun calendars(): Flow<List<CalendarSource>>
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
/** Re-query every open [calendars] / [instances] flow. */
fun refresh()
suspend fun eventDetail(eventId: Long): EventDetail suspend fun eventDetail(eventId: Long): EventDetail
/** /**
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.SearchCandidate
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -51,21 +52,37 @@ class CalendarRepositoryImpl @Inject constructor(
private suspend fun allDayReminderTimeMinutes(): Int = private suspend fun allDayReminderTimeMinutes(): Int =
settingsPrefs.allDayReminderTimeMinutes.first() settingsPrefs.allDayReminderTimeMinutes.first()
// DROP_OLDEST: tryEmit never fails, so a tick that lands while a subscriber
// still holds the previous one is never lost (#364).
private val ticks = MutableSharedFlow<Unit>( private val ticks = MutableSharedFlow<Unit>(
replay = 0, replay = 0,
extraBufferCapacity = 1, extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
) )
/** /**
* Bumped on every provider notification, so one tick's calendar read can be * Bumped on every [refresh], so one tick's calendar read can be
* shared by everything that needs it (see [calendarsSnapshot]). * shared by everything that needs it (see [calendarsSnapshot]).
*/ */
private val generation = AtomicLong(0L) private val generation = AtomicLong(0L)
init { init {
dataSource.registerChangeListener { dataSource.registerChangeListener(::refresh)
generation.incrementAndGet() }
ticks.tryEmit(Unit)
// Writes re-query on their own too: some providers never notify the
// observer, which left every view stale until a restart (#364).
override fun refresh() {
generation.incrementAndGet()
ticks.tryEmit(Unit)
}
/** Run a provider write on [io], then re-query whether or not it threw. */
private suspend fun <T> write(block: suspend () -> T): T = withContext(io) {
try {
block()
} finally {
refresh()
} }
} }
@@ -181,7 +198,7 @@ class CalendarRepositoryImpl @Inject constructor(
displayName: String, displayName: String,
color: Int, color: Int,
description: String?, description: String?,
): Long = withContext(io) { ): Long = write {
dataSource.createLocalCalendar(displayName, color, description) dataSource.createLocalCalendar(displayName, color, description)
} }
@@ -190,13 +207,13 @@ class CalendarRepositoryImpl @Inject constructor(
displayName: String, displayName: String,
color: Int, color: Int,
description: String?, description: String?,
) = withContext(io) { dataSource.updateCalendar(id, displayName, color, description) } ) = write { dataSource.updateCalendar(id, displayName, color, description) }
override suspend fun deleteCalendar(id: Long) = override suspend fun deleteCalendar(id: Long) =
withContext(io) { dataSource.deleteCalendar(id) } write { dataSource.deleteCalendar(id) }
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) = override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
withContext(io) { write {
if (dataSource.canWriteCalendars()) { if (dataSource.canWriteCalendars()) {
ids.forEach { dataSource.setCalendarVisible(it, visible) } ids.forEach { dataSource.setCalendarVisible(it, visible) }
// Nothing of ours is left waiting for the provider once the // Nothing of ours is left waiting for the provider once the
@@ -217,7 +234,7 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun importEvents( override suspend fun importEvents(
targetCalendarId: Long, targetCalendarId: Long,
events: List<ParsedIcsEvent>, events: List<ParsedIcsEvent>,
): IcsImportSummary = withContext(io) { ): IcsImportSummary = write {
val existing = dataSource.existingUids(targetCalendarId) val existing = dataSource.existingUids(targetCalendarId)
// Both are per-calendar, not per-event: looking them up once keeps a // Both are per-calendar, not per-event: looking them up once keeps a
// thousand-event restore to two extra queries. The palette is the // thousand-event restore to two extra queries. The palette is the
@@ -268,7 +285,7 @@ class CalendarRepositoryImpl @Inject constructor(
) )
} }
override suspend fun createEvent(form: EventForm): Long = withContext(io) { override suspend fun createEvent(form: EventForm): Long = write {
dataSource.insertEvent(form, allDayReminderTimeMinutes()) dataSource.insertEvent(form, allDayReminderTimeMinutes())
} }
@@ -276,11 +293,11 @@ class CalendarRepositoryImpl @Inject constructor(
eventId: Long, eventId: Long,
original: EventForm, original: EventForm,
updated: EventForm, updated: EventForm,
) = withContext(io) { ) = write {
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes()) dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
} }
override suspend fun deleteEvent(eventId: Long) = withContext(io) { override suspend fun deleteEvent(eventId: Long) = write {
dataSource.deleteEvent(eventId) dataSource.deleteEvent(eventId)
} }
@@ -289,7 +306,7 @@ class CalendarRepositoryImpl @Inject constructor(
targetCalendarId: Long, targetCalendarId: Long,
original: EventForm, original: EventForm,
updated: EventForm, updated: EventForm,
): Long = withContext(io) { ): Long = write {
dataSource.moveEvent( dataSource.moveEvent(
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(), eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
) )
@@ -300,7 +317,7 @@ class CalendarRepositoryImpl @Inject constructor(
beginMillis: Long, beginMillis: Long,
original: EventForm, original: EventForm,
form: EventForm, form: EventForm,
): Long = withContext(io) { ): Long = write {
dataSource.updateOccurrence( dataSource.updateOccurrence(
eventId, beginMillis, original, form, allDayReminderTimeMinutes(), eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
) )
@@ -311,20 +328,20 @@ class CalendarRepositoryImpl @Inject constructor(
beginMillis: Long, beginMillis: Long,
original: EventForm, original: EventForm,
updated: EventForm, updated: EventForm,
): Long = withContext(io) { ): Long = write {
dataSource.updateEventFromOccurrence( dataSource.updateEventFromOccurrence(
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(), eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
) )
} }
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) { override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write {
dataSource.deleteOccurrence(eventId, beginMillis) dataSource.deleteOccurrence(eventId, beginMillis)
} }
override suspend fun deleteEventFromOccurrence( override suspend fun deleteEventFromOccurrence(
eventId: Long, eventId: Long,
beginMillis: Long, beginMillis: Long,
) = withContext(io) { ) = write {
dataSource.deleteEventFromOccurrence(eventId, beginMillis) dataSource.deleteEventFromOccurrence(eventId, beginMillis)
} }
@@ -20,6 +20,8 @@ import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
import de.jeanlucmakiola.calendula.data.time.AndroidTodaySource
import de.jeanlucmakiola.calendula.data.time.TodaySource
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton import javax.inject.Singleton
@@ -61,6 +63,12 @@ abstract class DataBindModule {
abstract fun bindSpecialDatesCalendarSpec( abstract fun bindSpecialDatesCalendarSpec(
impl: AndroidSpecialDatesCalendarSpec, impl: AndroidSpecialDatesCalendarSpec,
): SpecialDatesCalendarSpec ): SpecialDatesCalendarSpec
@Binds
@Singleton
abstract fun bindTodaySource(
impl: AndroidTodaySource,
): TodaySource
} }
@Module @Module
@@ -0,0 +1,63 @@
package de.jeanlucmakiola.calendula.data.time
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.transformLatest
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import javax.inject.Inject
import kotlin.time.Clock
/** The current local date, re-emitted whenever it changes (#373). */
interface TodaySource {
val today: Flow<LocalDate>
}
/**
* Re-reads the date on every minute boundary while collected, and at once on the
* system's date, time and zone broadcasts, which can arrive late or not at all.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AndroidTodaySource @Inject constructor(
@ApplicationContext private val context: Context,
) : TodaySource {
override val today: Flow<LocalDate> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
trySend(Unit)
}
}
val filter = IntentFilter().apply {
addAction(Intent.ACTION_DATE_CHANGED)
addAction(Intent.ACTION_TIME_CHANGED)
addAction(Intent.ACTION_TIMEZONE_CHANGED)
}
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
awaitClose { context.unregisterReceiver(receiver) }
}
.onStart { emit(Unit) }
.transformLatest {
while (true) {
emit(currentDate())
delay(MINUTE_MILLIS - Clock.System.now().toEpochMilliseconds() % MINUTE_MILLIS)
}
}
.distinctUntilChanged()
}
private const val MINUTE_MILLIS = 60_000L
fun currentDate(): LocalDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
@@ -150,6 +150,15 @@ fun CalendarHost(
// The date every view opens on and carries along (#184); tapping a day // The date every view opens on and carries along (#184); tapping a day
// anywhere focuses it and opens the day view there. // anywhere focuses it and opens the day view there.
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) } val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) }
// A focus still on yesterday follows the date over, so the next view opened
// doesn't land on the old day (#373); one moved elsewhere stays put.
val today = viewModel.today.collectAsStateWithLifecycle().value
var focusToday by rememberSaveable { mutableStateOf(today.toString()) }
LaunchedEffect(today) {
val old = LocalDate.parse(focusToday)
if (old != today && focus.date == old) focus.date = today
focusToday = today.toString()
}
val switchScope = rememberCoroutineScope() val switchScope = rememberCoroutineScope()
val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move -> val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move ->
@@ -4,12 +4,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.LocalDate
import javax.inject.Inject import javax.inject.Inject
/** /**
@@ -21,8 +24,16 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class CalendarHostViewModel @Inject constructor( class CalendarHostViewModel @Inject constructor(
prefs: SettingsPrefs, prefs: SettingsPrefs,
todaySource: TodaySource,
) : ViewModel() { ) : ViewModel() {
/** Today, kept current across midnight so the shared focus can follow it (#373). */
val today: StateFlow<LocalDate> = todaySource.today.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = currentDate(),
)
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn( val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L), started = SharingStarted.WhileSubscribed(5_000L),
@@ -8,6 +8,8 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
@@ -21,6 +23,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -30,8 +33,6 @@ import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime import kotlinx.datetime.atTime
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -40,6 +41,7 @@ import javax.inject.Inject
class AgendaViewModel @Inject constructor( class AgendaViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs, settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
@@ -84,11 +86,25 @@ class AgendaViewModel @Inject constructor(
private val zone = TimeZone.currentSystemDefault() private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date get() = currentDate()
private val _anchor = MutableStateFlow(todayDate) private val _anchor = MutableStateFlow(todayDate)
val anchor: StateFlow<LocalDate> = _anchor val anchor: StateFlow<LocalDate> = _anchor
private var lastToday = todayDate
/**
* Today, kept current across midnight (#373). An agenda still starting on
* yesterday moves on to the new day; one jumped elsewhere stays put.
*/
private val today: StateFlow<LocalDate> = todaySource.today
.onEach { date ->
val old = lastToday
lastToday = date
if (old != date && _anchor.value == old) _anchor.value = date
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
// A transient, in-view override of the saved agenda range. Held in memory // A transient, in-view override of the saved agenda range. Held in memory
// (not persisted), so it survives view switches and rotation within the // (not persisted), so it survives view switches and rotation within the
// session but resets to the saved default when the app is relaunched. // session but resets to the saved default when the app is relaunched.
@@ -113,8 +129,9 @@ class AgendaViewModel @Inject constructor(
combine( combine(
repository.calendars(), repository.calendars(),
repository.instances(window), repository.instances(window),
) { calendars, instances -> today,
buildState(params, calendars, instances) ) { calendars, instances, today ->
buildState(params, calendars, instances, today)
} }
} }
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
@@ -156,6 +173,7 @@ class AgendaViewModel @Inject constructor(
params: AgendaParams, params: AgendaParams,
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
instances: List<EventInstance>, instances: List<EventInstance>,
today: LocalDate,
): AgendaUiState { ): AgendaUiState {
calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) } calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) }
val anchor = params.anchor val anchor = params.anchor
@@ -166,7 +184,7 @@ class AgendaViewModel @Inject constructor(
val days = groupAgendaDays(anchor, rangeEnd, instances, zone) val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
return AgendaUiState.Success( return AgendaUiState.Success(
anchor = anchor, anchor = anchor,
today = todayDate, today = today,
days = days, days = days,
range = params.range, range = params.range,
rangeIsOverride = params.rangeIsOverride, rangeIsOverride = params.rangeIsOverride,
@@ -87,6 +87,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -197,13 +198,17 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.YearMonth import kotlinx.datetime.YearMonth
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
private val LocalTodayPulse = staticCompositionLocalOf { 0 } private val LocalTodayPulse = staticCompositionLocalOf { 0 }
/** Live today for the pages, whose cached states may have been built before midnight (#373). */
private val LocalMonthToday = compositionLocalOf<LocalDate?> { null }
private fun MonthUiState.Success.withToday(today: LocalDate?): MonthUiState.Success =
if (today == null || today == this.today) this else copy(today = today)
private val LocalOpenWeek = staticCompositionLocalOf<((LocalDate) -> Unit)?> { null } private val LocalOpenWeek = staticCompositionLocalOf<((LocalDate) -> Unit)?> { null }
/** A brief push from today's full cell into its immediate neighbours. */ /** A brief push from today's full cell into its immediate neighbours. */
@@ -319,12 +324,7 @@ fun MonthScreen(
} }
val dense = viewStyle == MonthViewStyle.Dense val dense = viewStyle == MonthViewStyle.Dense
// Today, from whichever state is driving; the clock only covers the first val today by viewModel.today.collectAsStateWithLifecycle()
// frame, before either has loaded.
val today = when {
scrolling -> (continuousState as? ContinuousMonthUiState.Success)?.today
else -> (state as? MonthUiState.Success)?.today
} ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
// The two scrolling styles index their items differently — Continuous by // The two scrolling styles index their items differently — Continuous by
// month (which the week start can't move), Dense by week (which it can) — so // month (which the week start can't move), Dense by week (which it can) — so
@@ -520,6 +520,7 @@ fun MonthScreen(
LocalDimCutoff provides dimCutoff, LocalDimCutoff provides dimCutoff,
LocalMonthDrag provides chipDrag, LocalMonthDrag provides chipDrag,
LocalTodayPulse provides todayPulse, LocalTodayPulse provides todayPulse,
LocalMonthToday provides today,
LocalOpenWeek provides onOpenWeek, LocalOpenWeek provides onOpenWeek,
) { ) {
if (scrolling) { if (scrolling) {
@@ -773,6 +774,7 @@ private fun MonthPage(
) { ) {
val ym = yearMonthForIndex(index) val ym = yearMonthForIndex(index)
val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle() val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle()
val today = LocalMonthToday.current
val chipDrag = LocalMonthDrag.current val chipDrag = LocalMonthDrag.current
val morph = LocalMonthMorph.current val morph = LocalMonthMorph.current
val viewMorph = LocalViewMorph.current val viewMorph = LocalViewMorph.current
@@ -782,7 +784,7 @@ private fun MonthPage(
LocalViewMorph provides viewMorph.takeIf { morphActive }, LocalViewMorph provides viewMorph.takeIf { morphActive },
) { ) {
when (val s = state) { when (val s = state) {
is MonthUiState.Success -> page(index, s) is MonthUiState.Success -> page(index, s.withToday(today))
else -> loading() else -> loading()
} }
} }
@@ -1388,7 +1390,8 @@ private fun SplitMonthContent(
val ym = yearMonthForIndex(index) val ym = yearMonthForIndex(index)
val pageState by remember(ym, weekStart) { monthState(ym, weekStart) } val pageState by remember(ym, weekStart) { monthState(ym, weekStart) }
.collectAsStateWithLifecycle() .collectAsStateWithLifecycle()
val page = pageState as? MonthUiState.Success ?: return@HorizontalPager val page = (pageState as? MonthUiState.Success)?.withToday(LocalMonthToday.current)
?: return@HorizontalPager
// Only the settled page carries the drag and the morph tags: its // Only the settled page carries the drag and the morph tags: its
// neighbours repeat some of its dates and would claim them. // neighbours repeat some of its dates and would claim them.
val active = index == pagerState.settledPage val active = index == pagerState.settledPage
@@ -7,6 +7,8 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
@@ -45,7 +47,6 @@ import kotlinx.datetime.minus
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -57,6 +58,7 @@ private const val MONTH_CACHE_SPAN = 2
class MonthViewModel @Inject constructor( class MonthViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs, settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
@@ -90,7 +92,18 @@ class MonthViewModel @Inject constructor(
) )
private val todayDate: LocalDate private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date get() = currentDate()
private var lastToday = todayDate
/** Today, kept current across midnight so a grid left open doesn't mark yesterday (#373). */
val today: StateFlow<LocalDate> = todaySource.today
.onEach { date ->
val old = lastToday
lastToday = date
if (old != date) followToday(old, date)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month)) private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
val month: StateFlow<YearMonth> = _month val month: StateFlow<YearMonth> = _month
@@ -139,8 +152,9 @@ class MonthViewModel @Inject constructor(
combine( combine(
repository.calendars(), repository.calendars(),
repository.instances(monthGridRange(ym, ws, zone)), repository.instances(monthGridRange(ym, ws, zone)),
) { calendars, instances -> today,
buildState(ym, ws, calendars, instances) ) { calendars, instances, today ->
buildState(ym, ws, calendars, instances, today)
} }
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io) .flowOn(io)
@@ -211,8 +225,9 @@ class MonthViewModel @Inject constructor(
combine( combine(
repository.calendars(), repository.calendars(),
repository.instances(range), repository.instances(range),
) { calendars, instances -> today,
buildContinuousState(window, ws, style, calendars, instances) ) { calendars, instances, today ->
buildContinuousState(window, ws, style, calendars, instances, today)
} }
} }
// A load landing is the cue to reach further out. Widening from here // A load landing is the cue to reach further out. Widening from here
@@ -257,6 +272,7 @@ class MonthViewModel @Inject constructor(
style: MonthViewStyle, style: MonthViewStyle,
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
instances: List<EventInstance>, instances: List<EventInstance>,
today: LocalDate,
): ContinuousMonthUiState { ): ContinuousMonthUiState {
calendarListFailure(calendars)?.let { return ContinuousMonthUiState.Failure(it) } calendarListFailure(calendars)?.let { return ContinuousMonthUiState.Failure(it) }
// Bucketed once for the whole window: a row then takes the handful of // Bucketed once for the whole window: a row then takes the handful of
@@ -283,7 +299,7 @@ class MonthViewModel @Inject constructor(
emptyMap() emptyMap()
} }
return ContinuousMonthUiState.Success( return ContinuousMonthUiState.Success(
today = todayDate, today = today,
monthsByIndex = months, monthsByIndex = months,
weeksByIndex = weeks, weeksByIndex = weeks,
weekStart = weekStart, weekStart = weekStart,
@@ -333,6 +349,18 @@ class MonthViewModel @Inject constructor(
_selectedDate.value = null _selectedDate.value = null
} }
/**
* The date rolled over from [old] to [new]. A paged grid still on [old]'s
* month moves on to [new]'s and drops the day picked yesterday; one browsed
* elsewhere stays put. The scrolling styles only repaint the today marker.
*/
private fun followToday(old: LocalDate, new: LocalDate) {
if (viewStyle.value.isScrolling) return
if (_month.value != YearMonth(old.year, old.month)) return
_month.value = YearMonth(new.year, new.month)
_selectedDate.value = null
}
/** /**
* Track [_month] to the month a scrolling style is showing. Those styles * Track [_month] to the month a scrolling style is showing. Those styles
* navigate by scroll position and never set [_month] themselves, so it would * navigate by scroll position and never set [_month] themselves, so it would
@@ -358,12 +386,13 @@ class MonthViewModel @Inject constructor(
weekStart: DayOfWeek, weekStart: DayOfWeek,
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
instances: List<EventInstance>, instances: List<EventInstance>,
today: LocalDate,
): MonthUiState { ): MonthUiState {
calendarListFailure(calendars)?.let { return MonthUiState.Failure(it) } calendarListFailure(calendars)?.let { return MonthUiState.Failure(it) }
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone) val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
return MonthUiState.Success( return MonthUiState.Success(
month = ym, month = ym,
today = todayDate, today = today,
weeks = weeks, weeks = weeks,
instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone), instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone),
zone = zone, zone = zone,
@@ -461,6 +461,7 @@ private fun PagedTimelineScreen(
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
WeekContent( WeekContent(
state = state, state = state,
today = today,
pagerState = pagerState, pagerState = pagerState,
grid = grid, grid = grid,
kind = kind, kind = kind,
@@ -482,6 +483,7 @@ private fun PagedTimelineScreen(
@Composable @Composable
private fun WeekContent( private fun WeekContent(
state: WeekUiState, state: WeekUiState,
today: LocalDate,
pagerState: PagerState, pagerState: PagerState,
grid: PageGrid, grid: PageGrid,
kind: TimelineKind, kind: TimelineKind,
@@ -536,7 +538,7 @@ private fun WeekContent(
is WeekUiState.Success -> if (morph != null) { is WeekUiState.Success -> if (morph != null) {
TimelineMorphFrame( TimelineMorphFrame(
morph = morph, morph = morph,
today = s.today, today = today,
scrollState = scrollState, scrollState = scrollState,
dragController = dragController, dragController = dragController,
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -549,7 +551,7 @@ private fun WeekContent(
grid = grid, grid = grid,
kind = kind, kind = kind,
span = span, span = span,
today = s.today, today = today,
initialStripHeight = handoffStrip ?: s.allDayStripHeight(), initialStripHeight = handoffStrip ?: s.allDayStripHeight(),
scrollState = scrollState, scrollState = scrollState,
dragController = dragController, dragController = dragController,
@@ -618,7 +620,10 @@ private fun WeekPager(
} }
ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) { ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) {
WeekPage( WeekPage(
state = loaded ?: empty, // A cached page may predate midnight; today comes off the clock (#373).
state = remember(loaded, empty, today) {
loaded?.takeIf { it.today != today }?.copy(today = today) ?: loaded ?: empty
},
headerBias = kind.headerBias, headerBias = kind.headerBias,
allDayHeight = allDayHeight, allDayHeight = allDayHeight,
scrollState = scrollState, scrollState = scrollState,
@@ -7,6 +7,8 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.displayPriority import de.jeanlucmakiola.calendula.domain.displayPriority
@@ -27,6 +29,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -40,7 +43,6 @@ import kotlinx.datetime.plus
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import java.util.Locale import java.util.Locale
import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.abs import kotlin.math.abs
@@ -73,6 +75,7 @@ fun positionOf(anchor: LocalDate, rule: (LocalDate) -> PageGrid): PagerPosition
abstract class PagedTimelineViewModel( abstract class PagedTimelineViewModel(
private val repository: CalendarRepository, private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs, settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
private val io: CoroutineDispatcher, private val io: CoroutineDispatcher,
gridRule: Flow<(LocalDate) -> PageGrid>, gridRule: Flow<(LocalDate) -> PageGrid>,
initialRule: (LocalDate) -> PageGrid, initialRule: (LocalDate) -> PageGrid,
@@ -89,7 +92,7 @@ abstract class PagedTimelineViewModel(
) )
private val todayDate: LocalDate private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date get() = currentDate()
// A day on the visible page; the page itself comes from the grid, so a // A day on the visible page; the page itself comes from the grid, so a
// settings change re-frames the same days instead of jumping. // settings change re-frames the same days instead of jumping.
@@ -103,6 +106,17 @@ abstract class PagedTimelineViewModel(
val rule: StateFlow<(LocalDate) -> PageGrid> = val rule: StateFlow<(LocalDate) -> PageGrid> =
gridRule.stateIn(viewModelScope, SharingStarted.Eagerly, initialRule) gridRule.stateIn(viewModelScope, SharingStarted.Eagerly, initialRule)
private var lastToday = todayDate
/** Today, kept current across midnight so a page left open doesn't mark yesterday (#373). */
private val today: StateFlow<LocalDate> = todaySource.today
.onEach { date ->
val old = lastToday
lastToday = date
if (old != date) followToday(old, date)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
private val position: StateFlow<PagerPosition> = private val position: StateFlow<PagerPosition> =
combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) } combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) }
.distinctUntilChanged() .distinctUntilChanged()
@@ -141,8 +155,9 @@ abstract class PagedTimelineViewModel(
combine( combine(
repository.calendars(), repository.calendars(),
repository.instances(spanRange(span.start, span.count, zone)), repository.instances(spanRange(span.start, span.count, zone)),
) { calendars, instances -> today,
buildState(span, calendars, instances) ) { calendars, instances, today ->
buildState(span, calendars, instances, today)
} }
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io) .flowOn(io)
@@ -163,12 +178,20 @@ abstract class PagedTimelineViewModel(
} }
fun goToToday() = jumpTo(todayDate) fun goToToday() = jumpTo(todayDate)
/** The date rolled over; a page still showing [old] follows on to [new], one browsed elsewhere stays. */
private fun followToday(old: LocalDate, new: LocalDate) {
val anchor = _anchor.value
val grid = rule.value(anchor)
if (grid.pageFor(anchor) == grid.pageFor(old)) _anchor.value = new
}
fun goToDate(date: LocalDate) = jumpTo(date) fun goToDate(date: LocalDate) = jumpTo(date)
private fun buildState( private fun buildState(
span: DaySpan, span: DaySpan,
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
instances: List<EventInstance>, instances: List<EventInstance>,
today: LocalDate,
): WeekUiState { ): WeekUiState {
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) } calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) } val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
@@ -176,7 +199,7 @@ abstract class PagedTimelineViewModel(
val timed = instances.filterNot { it.isAllDay } val timed = instances.filterNot { it.isAllDay }
return WeekUiState.Success( return WeekUiState.Success(
weekStart = span.start, weekStart = span.start,
today = todayDate, today = today,
days = days, days = days,
allDaySpans = layoutAllDay(allDay, days, zone), allDaySpans = layoutAllDay(allDay, days, zone),
timedByDay = days.associateWith { day -> layoutDay(timed, day, zone) }, timedByDay = days.associateWith { day -> layoutDay(timed, day, zone) },
@@ -189,10 +212,12 @@ abstract class PagedTimelineViewModel(
class WeekViewModel @Inject constructor( class WeekViewModel @Inject constructor(
repository: CalendarRepository, repository: CalendarRepository,
settingsPrefs: SettingsPrefs, settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
@IoDispatcher io: CoroutineDispatcher, @IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel( ) : PagedTimelineViewModel(
repository = repository, repository = repository,
settingsPrefs = settingsPrefs, settingsPrefs = settingsPrefs,
todaySource = todaySource,
io = io, io = io,
gridRule = settingsPrefs.weekStart.map { pref -> gridRule = settingsPrefs.weekStart.map { pref ->
val grid = WeekGrid(pref.resolveFirstDay(Locale.getDefault())) val grid = WeekGrid(pref.resolveFirstDay(Locale.getDefault()))
@@ -211,10 +236,12 @@ class WeekViewModel @Inject constructor(
class MultiDayViewModel @Inject constructor( class MultiDayViewModel @Inject constructor(
repository: CalendarRepository, repository: CalendarRepository,
settingsPrefs: SettingsPrefs, settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
@IoDispatcher io: CoroutineDispatcher, @IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel( ) : PagedTimelineViewModel(
repository = repository, repository = repository,
settingsPrefs = settingsPrefs, settingsPrefs = settingsPrefs,
todaySource = todaySource,
io = io, io = io,
gridRule = settingsPrefs.multiDayCount.map { count -> gridRule = settingsPrefs.multiDayCount.map { count ->
{ anchor: LocalDate -> DaysGrid.startingAt(anchor, count) } { anchor: LocalDate -> DaysGrid.startingAt(anchor, count) }
@@ -227,10 +254,12 @@ class MultiDayViewModel @Inject constructor(
class DayViewModel @Inject constructor( class DayViewModel @Inject constructor(
repository: CalendarRepository, repository: CalendarRepository,
settingsPrefs: SettingsPrefs, settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
@IoDispatcher io: CoroutineDispatcher, @IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel( ) : PagedTimelineViewModel(
repository = repository, repository = repository,
settingsPrefs = settingsPrefs, settingsPrefs = settingsPrefs,
todaySource = todaySource,
io = io, io = io,
gridRule = flowOf { _: LocalDate -> DayGrid }, gridRule = flowOf { _: LocalDate -> DayGrid },
initialRule = { DayGrid }, initialRule = { DayGrid },
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
@@ -28,11 +29,12 @@ import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* How far either side of today the month widget pre-loads. The displayed month * Months either side of the displayed one the month widget loads, so a few arrow
* is chosen reactively in the composition, so one wide read covers ~13 months of * taps page without a re-query. Kept small: every load expands recurrences across
* prev/next navigation without re-querying on every arrow tap. * the whole window, and on slow devices a wide one held the widget on "loading"
* (#363).
*/ */
private const val MONTH_WIDGET_RANGE_DAYS = 400 private const val MONTH_WIDGET_PAD_MONTHS = 3
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault() internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
@@ -76,6 +78,8 @@ sealed interface AgendaWidgetData {
val savedShowToday: Boolean, val savedShowToday: Boolean,
/** Saved widget size step (#103) — the fallback before Glance state is set. */ /** Saved widget size step (#103) — the fallback before Glance state is set. */
val savedWidgetSize: WidgetSize, val savedWidgetSize: WidgetSize,
/** Last day [days] covers; a wider range picked later needs a reload. */
val loadedThrough: LocalDate,
/** Snapshot instant the data was read at, for "has this event ended?" tests. */ /** Snapshot instant the data was read at, for "has this event ended?" tests. */
val now: Instant, val now: Instant,
) : AgendaWidgetData ) : AgendaWidgetData
@@ -94,14 +98,15 @@ sealed interface MonthWidgetSource {
val today: LocalDate, val today: LocalDate,
val weekStart: DayOfWeek, val weekStart: DayOfWeek,
val instances: List<EventInstance>, val instances: List<EventInstance>,
/** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */
val months: IntRange,
) : MonthWidgetSource ) : MonthWidgetSource
} }
/** /**
* Process-lived cache of the wide month window. Month navigation re-runs * Process-lived cache of the month window. Month navigation can re-run
* `provideGlance` (via `updateAll`), and re-querying ~13 months of instances on * `provideGlance`, and re-querying on every arrow tap is what made paging feel
* every arrow tap is what made paging feel sluggish — so we load once and reuse * sluggish — so we load once and reuse the same snapshot for every nearby month. Invalidated by
* the same snapshot for every nearby month. Invalidated by
* [invalidateMonthWidgetCache] when calendar data changes (the freshness * [invalidateMonthWidgetCache] when calendar data changes (the freshness
* receiver), and automatically when the day rolls over (the `today` guard). * receiver), and automatically when the day rolls over (the `today` guard).
*/ */
@@ -118,8 +123,11 @@ internal fun invalidateMonthWidgetCache() {
* One-shot read of the upcoming agenda for the widget. Reuses the app's * One-shot read of the upcoming agenda for the widget. Reuses the app's
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's * [agendaRange] window and [groupAgendaDays] grouping, and the repository's
* [first]-emitted snapshot already has hidden calendars filtered out. * [first]-emitted snapshot already has hidden calendars filtered out.
*
* Loads [range] (the widget's own, or the saved pref when null) rather than the
* widest selectable window (#363).
*/ */
internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { internal suspend fun Context.loadAgendaWidgetData(range: AgendaRange? = null): AgendaWidgetData {
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
val zone = systemZone() val zone = systemZone()
val anchor = today(zone) val anchor = today(zone)
@@ -129,11 +137,11 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
val savedPastDisplay = prefs.pastEventDisplay.first() val savedPastDisplay = prefs.pastEventDisplay.first()
val showToday = prefs.agendaShowToday.first() val showToday = prefs.agendaShowToday.first()
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault()) val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
// Load the widest selectable window once; the displayed range is sliced in // The displayed range is still sliced in the composition from Glance state;
// the composition from Glance state, so changing the range is a plain // a wider one picked later reloads there (see loadedThrough).
// recomposition and never depends on the widget session restarting. val dayCount = (range ?: savedRange).dayCount(anchor, weekStart)
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone) val window = agendaRange(anchor, dayCount - 1, zone)
val windowEnd = anchor.plus(AgendaRange.MAX_CUSTOM_DAYS - 1, DateTimeUnit.DAY) val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
val instances = ep.calendarRepository().instances(window).first() val instances = ep.calendarRepository().instances(window).first()
val is24Hour = prefs.timeFormat.first() val is24Hour = prefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(this)) .is24Hour(android.text.format.DateFormat.is24HourFormat(this))
@@ -148,22 +156,39 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
savedPastDisplay = savedPastDisplay, savedPastDisplay = savedPastDisplay,
savedShowToday = showToday, savedShowToday = showToday,
savedWidgetSize = prefs.widgetSize.first(), savedWidgetSize = prefs.widgetSize.first(),
loadedThrough = windowEnd,
now = Clock.System.now(), now = Clock.System.now(),
) )
} }
/** One-shot wide read backing the month widget's grid for any nearby month. */ internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.ordinal
internal suspend fun Context.loadMonthWidgetSource(): MonthWidgetSource {
private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, index % 12 + 1, 1)
/** Read backing the month widget's grid for [centerIndex] and the months around it. */
internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): MonthWidgetSource {
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
val zone = systemZone() val zone = systemZone()
val anchor = today(zone) val anchor = today(zone)
val center = centerIndex ?: monthIndexOf(anchor)
// Reuse the cached window unless the day changed (then it's stale for "today"). // Reuse the cached window unless the day changed (then it's stale for "today").
MonthWidgetCache.data?.let { if (it.today == anchor) return it } MonthWidgetCache.data?.let { if (it.today == anchor && center in it.months) return it }
val ep = widgetEntryPoint() val ep = widgetEntryPoint()
val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault()) val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
val from = anchor.minus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atStartOfDayIn(zone) val months = (center - MONTH_WIDGET_PAD_MONTHS)..(center + MONTH_WIDGET_PAD_MONTHS)
val to = anchor.plus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone) // Padded by the grid's spill into the neighbouring months.
val from = firstDayOfMonthIndex(months.first)
.minus(1, DateTimeUnit.WEEK)
.atStartOfDayIn(zone)
val to = firstDayOfMonthIndex(months.last + 1)
.plus(2, DateTimeUnit.WEEK)
.atTime(23, 59, 59)
.toInstant(zone)
val instances = ep.calendarRepository().instances(from..to).first() val instances = ep.calendarRepository().instances(from..to).first()
return MonthWidgetSource.Ready(today = anchor, weekStart = weekStart, instances = instances) return MonthWidgetSource.Ready(
.also { MonthWidgetCache.data = it } today = anchor,
weekStart = weekStart,
instances = instances,
months = months,
).also { MonthWidgetCache.data = it }
} }
@@ -5,8 +5,14 @@ import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.ColorFilter import androidx.glance.ColorFilter
@@ -28,6 +34,7 @@ import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.provideContent
import androidx.glance.background import androidx.glance.background
import androidx.glance.currentState import androidx.glance.currentState
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.layout.Alignment import androidx.glance.layout.Alignment
import androidx.glance.layout.Box import androidx.glance.layout.Box
@@ -125,7 +132,8 @@ class AgendaWidget : GlanceAppWidget() {
override val sizeMode = SizeMode.Single override val sizeMode = SizeMode.Single
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val data = context.loadAgendaWidgetData() val stored = getAppWidgetState<Preferences>(context, id)[AGENDA_RANGE_KEY]
val data = context.loadAgendaWidgetData(stored?.let { parseAgendaRange(it, AgendaRange.Month) })
val dark = (context.resources.configuration.uiMode and val dark = (context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
provideContent { provideContent {
@@ -190,71 +198,95 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics) AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
is AgendaWidgetData.Ready -> { is AgendaWidgetData.Ready -> {
// Range read reactively from per-instance Glance state (falls back // Range read reactively from per-instance Glance state (falls back
// to the saved pref for a freshly placed widget), then the wide // to the saved pref for a freshly placed widget), then the loaded
// pre-loaded window is sliced to it — pure recomposition. // window is sliced to it — pure recomposition.
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange) val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
val pastDisplay = val context = androidx.glance.LocalContext.current
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay) var loaded by remember(data) { mutableStateOf<AgendaWidgetData.Ready>(data) }
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday // A range wider than what was loaded reloads in a live session,
val rangeEnd = data.today.plus( // where provideGlance doesn't run again (#363).
range.dayCount(data.today, data.weekStart) - 1, LaunchedEffect(range) {
DateTimeUnit.DAY, val end = loaded.today.plus(
) range.dayCount(loaded.today, loaded.weekStart) - 1,
// Slice to the chosen range, then drop finished events (and any day DateTimeUnit.DAY,
// they leave empty) when the mode is Hide — so "Upcoming" really is. )
// Finally re-anchor today (as an empty placeholder day) when the if (end > loaded.loadedThrough) {
// "always show today" pref is on and today has no events left (#35). (context.loadAgendaWidgetData(range) as? AgendaWidgetData.Ready)
val visibleDays = anchorTodayIfMissing( ?.let { loaded = it }
days = data.days }
.filter { it.date <= rangeEnd } }
.let { days -> AgendaRows(data = loaded, range = range, dark = dark, metrics = metrics)
if (pastDisplay == PastEventDisplay.HIDE) { }
days.mapNotNull { day -> }
val remaining = day.events.filterNot { it.hasEnded(data.now) } }
if (remaining.isEmpty()) null else day.copy(events = remaining) }
}
} else { @Composable
days private fun AgendaRows(
} data: AgendaWidgetData.Ready,
}, range: AgendaRange,
today = data.today, dark: Boolean,
enabled = showToday, metrics: AgendaMetrics,
) ) {
if (visibleDays.isEmpty()) { val pastDisplay =
WidgetMessage(R.string.agenda_empty_title, metrics) parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
val rangeEnd = data.today.plus(
range.dayCount(data.today, data.weekStart) - 1,
DateTimeUnit.DAY,
)
// Slice to the chosen range, then drop finished events (and any day
// they leave empty) when the mode is Hide — so "Upcoming" really is.
// Finally re-anchor today (as an empty placeholder day) when the
// "always show today" pref is on and today has no events left (#35).
val visibleDays = anchorTodayIfMissing(
days = data.days
.filter { it.date <= rangeEnd }
.let { days ->
if (pastDisplay == PastEventDisplay.HIDE) {
days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(data.now) }
if (remaining.isEmpty()) null else day.copy(events = remaining)
}
} else { } else {
val rows = buildList { days
visibleDays.forEach { day -> }
add(AgendaRow.Header(day.date, data.today)) },
if (day.events.isEmpty()) { today = data.today,
add(AgendaRow.Placeholder(day.date)) enabled = showToday,
} else { )
day.events.forEach { add(AgendaRow.Event(day.date, it)) } if (visibleDays.isEmpty()) {
} WidgetMessage(R.string.agenda_empty_title, metrics)
} } else {
} val rows = buildList {
// Bound the payload, then drop a day header the cut left visibleDays.forEach { day ->
// stranded with nothing under it. add(AgendaRow.Header(day.date, data.today))
.take(MAX_AGENDA_ROWS) if (day.events.isEmpty()) {
.dropLastWhile { it is AgendaRow.Header } add(AgendaRow.Placeholder(day.date))
LazyColumn(modifier = GlanceModifier.fillMaxSize()) { } else {
items(rows.size) { index -> day.events.forEach { add(AgendaRow.Event(day.date, it)) }
when (val row = rows[index]) { }
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics) }
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics) }
is AgendaRow.Event -> EventRow( // Bound the payload, then drop a day header the cut left
event = row.event, // stranded with nothing under it.
day = row.date, .take(MAX_AGENDA_ROWS)
dark = dark, .dropLastWhile { it is AgendaRow.Header }
soften = data.soften, LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
is24Hour = data.is24Hour, items(rows.size) { index ->
dimmed = pastDisplay == PastEventDisplay.DIM && when (val row = rows[index]) {
row.event.hasEnded(data.now), is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
metrics = metrics, is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
) is AgendaRow.Event -> EventRow(
} event = row.event,
} day = row.date,
} dark = dark,
soften = data.soften,
is24Hour = data.is24Hour,
dimmed = pastDisplay == PastEventDisplay.DIM &&
row.event.hasEnded(data.now),
metrics = metrics,
)
} }
} }
} }
@@ -5,8 +5,14 @@ import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.glance.ColorFilter import androidx.glance.ColorFilter
import androidx.glance.GlanceId import androidx.glance.GlanceId
@@ -26,6 +32,7 @@ import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.background import androidx.glance.background
import androidx.glance.currentState import androidx.glance.currentState
@@ -59,6 +66,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
import de.jeanlucmakiola.calendula.widget.monthIndexOf
import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today import de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
@@ -81,10 +89,7 @@ private val LANE_HEIGHT = 14.dp
private val DAY_NUMBER_HEIGHT = 18.dp private val DAY_NUMBER_HEIGHT = 18.dp
private val GRID_HPADDING = 8.dp private val GRID_HPADDING = 8.dp
private fun currentMonthIndex(zone: TimeZone): Int { private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone))
val t = today(zone)
return t.year * 12 + t.month.ordinal
}
private fun yearMonthOf(index: Int): YearMonth = private fun yearMonthOf(index: Int): YearMonth =
YearMonth(index / 12, Month(index % 12 + 1)) YearMonth(index / 12, Month(index % 12 + 1))
@@ -123,7 +128,8 @@ class MonthWidget : GlanceAppWidget() {
override val sizeMode = SizeMode.Exact override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val source = context.loadMonthWidgetSource() val shown = getAppWidgetState<Preferences>(context, id)[MONTH_INDEX_KEY]
val source = context.loadMonthWidgetSource(shown)
val dark = (context.resources.configuration.uiMode and val dark = (context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
// Read fresh (not through the cached source) so toggling the softener // Read fresh (not through the cached source) so toggling the softener
@@ -198,18 +204,28 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo
val zone = systemZone() val zone = systemZone()
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone) val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
val ym = yearMonthOf(index) val ym = yearMonthOf(index)
// Paging past the loaded months reloads around the new one in a
// live session, where provideGlance doesn't run again.
val context = LocalContext.current
var loaded by remember(source) { mutableStateOf<MonthWidgetSource.Ready>(source) }
LaunchedEffect(index) {
if (index !in loaded.months) {
(context.loadMonthWidgetSource(index) as? MonthWidgetSource.Ready)
?.let { loaded = it }
}
}
// Column width from the live widget size, minus our H padding. // Column width from the live widget size, minus our H padding.
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7 val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone) val weeks = layoutMonthWeeks(ym, loaded.weekStart, loaded.instances, zone)
MonthHeader(label = monthLabel(ym, source.today.year)) MonthHeader(label = monthLabel(ym, loaded.today.year))
Spacer(GlanceModifier.height(2.dp)) Spacer(GlanceModifier.height(2.dp))
WeekdayHeader(weekStart = source.weekStart, colW = colW) WeekdayHeader(weekStart = loaded.weekStart, colW = colW)
weeks.forEach { week -> weeks.forEach { week ->
WeekRow( WeekRow(
week = week, week = week,
currentMonth = ym.month, currentMonth = ym.month,
today = source.today, today = loaded.today,
dark = dark, dark = dark,
soften = soften, soften = soften,
colW = colW, colW = colW,
@@ -95,6 +95,45 @@ class CalendarRepositoryImplTest {
} }
} }
@Test
fun `instances re-emit after a write the provider never notified about`(@TempDir tempDir: Path) = runTest {
var current = listOf(makeEvent(10L))
val fake = FakeCalendarDataSource().apply { instancesResult = { _, _ -> current } }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
assertThat(awaitItem().map { it.eventId }).containsExactly(10L)
current = emptyList()
repo.deleteEvent(10L)
assertThat(awaitItem()).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `instances re-emit after a write that failed`(@TempDir tempDir: Path) = runTest {
var current = listOf(makeEvent(10L))
val fake = FakeCalendarDataSource().apply {
instancesResult = { _, _ -> current }
writeError = WriteFailedException("boom")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
awaitItem()
current = emptyList()
assertThrows<WriteFailedException> { repo.deleteOccurrence(10L, 1_000L) }
assertThat(awaitItem()).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `instances forwards epoch-millis bounds to data source`(@TempDir tempDir: Path) = runTest { fun `instances forwards epoch-millis bounds to data source`(@TempDir tempDir: Path) = runTest {
var observedBegin: Long? = null var observedBegin: Long? = null
+4 -2
View File
@@ -12,8 +12,10 @@ the package list (recurring writes, save conflicts, reminder delivery).
straight back to it. Sync is DAVx5's / Google's / the system's job. straight back to it. Sync is DAVx5's / Google's / the system's job.
2. **Observer-driven UI.** A `ContentObserver` on the provider triggers 2. **Observer-driven UI.** A `ContentObserver` on the provider triggers
re-queries; every screen recomposes from fresh provider state. After a re-queries; every screen recomposes from fresh provider state. After a
write, nothing is patched by hand — the provider notifies, the views write, nothing is patched by hand — the views refresh from the provider.
refresh. This also covers external changes (sync) for free. This also covers external changes (sync) for free. Some providers don't
reliably notify (#364), so the repository also re-queries after each of
its own writes, and `MainActivity` does on every return to the foreground.
3. **JVM-first testing.** Everything between the UI and the 3. **JVM-first testing.** Everything between the UI and the
`ContentResolver` is shaped so it runs as a plain JUnit 5 test: pure `ContentResolver` is shaped so it runs as a plain JUnit 5 test: pure
domain logic, cursor-free mappers, a `FakeCalendarDataSource` for domain logic, cursor-free mappers, a `FakeCalendarDataSource` for
+75 -7
View File
@@ -5,34 +5,56 @@ built, signed, and published automatically by `.gitea/workflows/release.yaml`
when a **bumped `versionName` reaches `main`** — the pipeline then creates the when a **bumped `versionName` reaches `main`** — the pipeline then creates the
matching `vX.Y.Z` tag and Gitea release itself. matching `vX.Y.Z` tag and Gitea release itself.
Before a stable release you can ship **betas** (`X.Y.Z-beta.N`) from the
release branch. They go to **Codeberg only**, flagged as pre-releases; F-Droid
and Play never see them. See [Cutting a beta](#cutting-a-beta).
## Versioning — the committed version is the source of truth ## Versioning — the committed version is the source of truth
A release is defined by the `versionName`/`versionCode` committed in A release is defined by the `versionName`/`versionCode` committed in
`app/build.gradle.kts`: `app/build.gradle.kts`:
- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `2.1.0`) - `versionName` = `MAJOR.MINOR.PATCH` (e.g. `2.22.4`), or
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`2.1.0` → `20100`) `MAJOR.MINOR.PATCH-beta.N` for a beta (e.g. `2.23.0-beta.2`)
- `versionCode` is derived from it by `scripts/version_info.sh`:
So `MINOR` and `PATCH` each have room for 0–99. The release pipeline reads | `versionName` | `versionCode` | Example |
| --- | --- | --- |
| `X.Y.Z` up to 2.22.3 (legacy) | `X*10000 + Y*100 + Z` | `2.22.3` → `22203` |
| `X.Y.Z-beta.N` (N = 1–98) | `X*1000000 + Y*10000 + Z*100 + N` | `2.23.0-beta.2` → `2230002` |
| `X.Y.Z` from 2.22.4 | `X*1000000 + Y*10000 + Z*100 + 99` | `2.22.4` → `2220499` |
A beta's code sits below its own stable release and above everything before
it, so a beta install updates in place to the next beta and then to the stable
version. Every version up to 2.22.3 keeps the code it already shipped with, and
`2.22.4` is the first on the new scheme. `MINOR` and `PATCH` each have room for
0–99. Run `scripts/version_info.sh` to see what the committed version resolves
to; CI fails a PR whose committed `versionCode` doesn't match, because the
official F-Droid repo builds the tag exactly as committed.
The release pipeline reads
`versionName`, pins `versionCode` to the derived value, builds, and — once the `versionName`, pins `versionCode` to the derived value, builds, and — once the
APK is published — creates the tag `v<versionName>` at that commit. The tag is APK is published — creates the tag `v<versionName>` at that commit. The tag is
an **output** of a successful release, not its trigger, so a tag always marks a an **output** of a successful release, not its trigger, so a tag always marks a
fully-shipped version (and a failure before publish leaves no tag, so re-running fully-shipped version (and a failure before publish leaves no tag, so re-running
the workflow safely retries). the workflow safely retries).
Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`→20000. Published version codes so far: `v0.1.0`→100 … `v2.0.0`→20000 …
`v2.22.3`→22203, then `v2.22.4`→2220499.
## Cutting a release ## Cutting a release
1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the 1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the
feature/fix branches that make up this release into it. This branch is the feature/fix branches that make up this release into it. This branch is the
release candidate — everything below happens on it, before it reaches `main`. release candidate — everything below happens on it, before it reaches `main`.
To ship betas first, follow [Cutting a beta](#cutting-a-beta) from here and
come back to step 2 when going stable.
2. Move the `## [Unreleased]` section of `CHANGELOG.md` under a new 2. Move the `## [Unreleased]` section of `CHANGELOG.md` under a new
`## [X.Y.Z] — <date>` heading (Keep a Changelog format). The text between `## [X.Y.Z] — <date>` heading (Keep a Changelog format). The text between
that heading and the next `## [` becomes both the Gitea release notes and that heading and the next `## [` becomes both the Gitea release notes and
the F-Droid per-version changelog. the F-Droid per-version changelog.
3. Bump the committed `versionName` (and `versionCode`) in 3. Bump the committed `versionName` (and `versionCode`, which
`app/build.gradle.kts` to the new version. **This bump is what triggers the `scripts/version_info.sh` prints) in `app/build.gradle.kts` to the new version. **This bump is what triggers the
release** when the branch merges to `main`. Then write the per-version release** when the branch merges to `main`. Then write the per-version
"What's New" by hand to "What's New" by hand to
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` and commit it. `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` and commit it.
@@ -89,6 +111,48 @@ Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`
> The `releaseTest` build type exists only for step 4 — it is never published. > The `releaseTest` build type exists only for step 4 — it is never published.
> The pipeline always builds and signs the real `release` variant. > The pipeline always builds and signs the real `release` variant.
## Cutting a beta
A beta is a test build of the next version, published as a **pre-release on
Codeberg** and nowhere else. It is signed with the real app key, so testers
install it over their stable install and it updates in place to later betas
and to the stable release.
1. **On `release/vX.Y.Z`**, with the features merged in, set
`versionName = "X.Y.Z-beta.1"` and the matching `versionCode`
(`scripts/version_info.sh` prints it; `2.23.0-beta.1` → `2230001`).
No What's New file — betas don't ship to the stores. Notes come from a
`## [X.Y.Z-beta.N]` section of `CHANGELOG.md` if you write one, otherwise
from `## [Unreleased]`. So keep the entries under `## [Unreleased]` while
betas are going out and only move them to `## [X.Y.Z]` when going stable:
once they're moved, a beta's notes come out empty.
2. **Optionally verify it on a device** with `scripts/verify-release.sh`, as for
a stable release.
3. **Push the branch to Codeberg.** When it reaches Gitea, `beta.yaml` sees a
beta whose Codeberg pre-release doesn't carry its APK yet, runs the unit
tests, builds and signs the APK, creates the `vX.Y.Z-beta.1` tag + a Gitea
pre-release (with the R8 mapping) and publishes the **Codeberg
pre-release** with the APK + `.sha256`. If that publish fails part-way, the
next push of the branch redoes it.
4. **Next round:** bump to `-beta.2` (and its `versionCode`) and push again.
5. **Going stable:** set `versionName = "X.Y.Z"` and its `versionCode`
(`2.23.0` → `2230099`), then continue from step 2 of
[Cutting a release](#cutting-a-release).
Who gets a beta:
- **Obtainium** users only with *Include prereleases* switched on for the app.
That is how a tester opts in; everyone else stays on stable.
- **F-Droid** (self-hosted and official) never: `beta.yaml` doesn't touch the
self-hosted repo, and the official recipe's `UpdateCheckMode: Tags ^v[0-9.]+$`
ignores `-beta` tags. Keep that pattern if the recipe ever changes.
- **Play** never.
Guards: a beta version can't reach `main` (CI fails the PR, and `release.yaml`'s
`detect` refuses one as a backstop), `beta.yaml` refuses a beta that isn't
newer than the latest stable release, and betas start at 2.22.4 (the legacy
codes have no room below them).
## What the pipeline does ## What the pipeline does
CI and release are split so a change is built once on its PR and only does CI and release are split so a change is built once on its PR and only does
@@ -108,6 +172,10 @@ release work when a merge actually cuts a release:
mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum
(both best-effort). Ordinary merges with no version bump fall through `detect` (both best-effort). Ordinary merges with no version bump fall through `detect`
and do nothing. and do nothing.
- **`beta.yaml`** (on push to `release/**`) — when the committed `versionName`
is a beta with no tag yet: unit tests, build & sign with the app key, Gitea
pre-release with the R8 mapping, Codeberg pre-release with the APK +
`.sha256`. Nothing else; see [Cutting a beta](#cutting-a-beta).
- **`play` job** (same workflow, after `release`) — uploads the App Bundle to - **`play` job** (same workflow, after `release`) — uploads the App Bundle to
Google Play. Runs last and separately so a Play rejection can't endanger a Google Play. Runs last and separately so a Play rejection can't endanger a
release that already shipped; skips cleanly until Play is configured. release that already shipped; skips cleanly until Play is configured.
@@ -184,7 +252,7 @@ on every pull request:
| `images/icon.png` | 512×512, 32-bit PNG | | `images/icon.png` | 512×512, 32-bit PNG |
| `images/featureGraphic.png` | 1024×500, JPEG or 24-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 | | `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 | | locale directories | exactly the ones in `fastlane/store-locales.txt`, which maps every app `values-*` to a Play locale code; the code must be one Play accepts (`nl-NL`, not `nl`) |
A locale without its own images falls back to en-US in both stores, so graphics 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 only need to exist there. Adding an app language means adding its line to
@@ -0,0 +1,7 @@
لم يصل الإصداران 2.22.0 و2.22.1 إلى Google Play، لذا هذا أول إصدار 2.22 هناك. التطبيق مطابق للإصدار 2.22.1.
• يتنقّل اليوم والأسبوع والشهر بمُنزلق يتبع إصبعك.
• يعرض العرض متعدد الأيام عدداً قابلاً للتخصيص من الأيام جنباً إلى جنب.
• الساعات المرئية: يملأ نطاق ساعات تختاره الشاشة.
• يبرز اليوم في شبكة الشهر.
• يُفتح الرابط في موقع الحدث في المتصفح.
@@ -0,0 +1 @@
عاد اسم صفحة المتجر إلى "Calendula: التقويم". لم يتغير شيء في التطبيق.
@@ -0,0 +1,3 @@
إصلاحات
• الأحداث المعدّلة أو المنقولة أو المحذوفة تظهر الآن فورًا على كل الهواتف. على بعض الهواتف، ومنها سامسونج، كان الحدث المعدّل يبقى كما هو حتى إعادة تشغيل التطبيق.
• أدوات الشاشة الرئيسية تحمّل الآن الأحداث التي تعرضها فقط. هذا يُفترض أن يساعد الأدوات التي كانت تبقى على «جارٍ التحميل» في الهواتف الأبطأ.
@@ -0,0 +1,2 @@
إصلاحات
• عند فتح التطبيق في صباح اليوم التالي يظهر اليوم الصحيح. كان عرض الشهر يعلّم الأمس على أنه اليوم ويُبقي اليوم الذي اخترته في المساء السابق محددًا. الآن تنتقل كل العروض إلى اليوم الجديد.
+1 -1
View File
@@ -1 +1 @@
Calendula Calendula: التقويم
@@ -0,0 +1,7 @@
Вэрсіі 2.22.0 і 2.22.1 не трапілі ў Google Play, таму гэта першая вэрсія 2.22 там. Праграма такая ж, як 2.22.1.
• Дзень, Тыдзень і Месяц перагортваюцца пэйджарам, які сочыць за пальцам.
• Шматдзённы выгляд паказвае наладжвальную колькасць дзён побач.
• Бачныя гадзіны: абраны дыяпазон гадзін запаўняе экран.
• Сёньня вылучаецца ў сетцы месяца.
• Спасылка ў месцы падзеі адкрываецца ў браўзеры.
@@ -0,0 +1 @@
Старонка ў краме зноў называецца «Calendula: Каляндар». Праграма не змянілася.
@@ -0,0 +1,3 @@
Выпраўлена
• Змененыя, перанесеныя і выдаленыя падзеі цяпер адразу адлюстроўваюцца на любым тэлефоне. На некаторых, у тым ліку Samsung, змененая падзея заставалася ранейшай да перазапуску праграмы.
• Віджэты на галоўным экране загружаюць толькі тыя падзеі, якія паказваюць. Гэта павінна дапамагчы віджэтам, якія на павольных тэлефонах затрымліваліся на «загрузцы».
@@ -0,0 +1,2 @@
Выпраўлена
• Калі адкрыць праграму наступнай раніцай, яна паказвае правільны дзень. Раней месяц адзначаў учорашні дзень як сённяшні і пакідаў выбраным дзень, які вы абралі ўвечары. Цяпер усе віды пераходзяць на новы дзень.
+1 -1
View File
@@ -1 +1 @@
Calendula Calendula: Каляндар
@@ -0,0 +1,7 @@
Verze 2.22.0 a 2.22.1 se na Google Play nedostaly, takže tohle je tam první verze 2.22. Aplikace je stejná jako 2.22.1.
• Den, Týden a Měsíc listují stránkovačem, který sleduje prst.
• Vícedenní zobrazení ukazuje nastavitelný počet dní vedle sebe.
• Viditelné hodiny: zvolený rozsah hodin vyplní obrazovku.
• Dnes vyniká v mřížce měsíce.
• Odkaz v místě události se otevře v prohlížeči.
@@ -0,0 +1 @@
Stránka v obchodě se opět jmenuje „Calendula: Kalendář“. Aplikace se nezměnila.
@@ -0,0 +1,3 @@
Opraveno
• Upravené, přesunuté a smazané události se teď na každém telefonu projeví hned. Na některých, mimo jiné Samsungu, zůstala změněná událost beze změny až do restartu aplikace.
• Widgety na domovské obrazovce načítají jen události, které zobrazují. To by mělo pomoct widgetům, které na pomalejších telefonech zůstávaly na „načítání“.
@@ -0,0 +1,2 @@
Opraveno
• Když aplikaci otevřete druhý den ráno, ukáže správný den. Měsíc dřív označoval včerejšek jako dnešek a nechával vybraný den, který jste zvolili večer předtím. Všechna zobrazení teď přejdou na nový den.
+1 -1
View File
@@ -1 +1 @@
Calendula Calendula: Kalendář
@@ -0,0 +1,7 @@
Google Play hat 2.22.0 und 2.22.1 verpasst, deshalb ist das dort die erste 2.22-Version. Die App ist dieselbe wie 2.22.1.
• Tag, Woche und Monat lassen sich mit einem fingerfolgenden Pager wischen.
• Mehrtagesansicht: einstellbare Anzahl Tage nebeneinander.
• Sichtbare Stunden: ein gewählter Stundenbereich füllt den Bildschirm.
• Heute hebt sich im Monatsraster ab.
• Ein Link im Ort eines Termins öffnet sich im Browser.
@@ -0,0 +1 @@
Der Store-Eintrag heißt wieder „Calendula: Kalender“. An der App hat sich nichts geändert.
@@ -0,0 +1,3 @@
Behoben
• Bearbeitete, verschobene und gelöschte Termine erscheinen jetzt auf jedem Handy sofort richtig. Auf manchen Geräten, darunter Samsung, blieb ein geänderter Termin unverändert, bis die App neu gestartet wurde.
• Die Widgets auf dem Startbildschirm laden nur noch die Termine, die sie anzeigen. Das sollte Widgets helfen, die auf langsameren Handys bei „Wird geladen“ hängen blieben.
@@ -0,0 +1,2 @@
Behoben
• Wird die App am nächsten Morgen geöffnet, zeigt sie den richtigen Tag. Die Monatsansicht markierte bisher gestern als heute und ließ den am Vorabend gewählten Tag ausgewählt. Jetzt wechseln alle Ansichten auf den neuen Tag.
+1 -1
View File
@@ -1 +1 @@
Calendula Calendula: Kalender
@@ -0,0 +1,7 @@
Google Play missed 2.22.0 and 2.22.1, so this is the first 2.22 release there. The app is the same as 2.22.1.
• Day, Week and Month follow your finger when you swipe between dates.
• A multi-day view shows a set number of days side by side.
• Visible hours fits the hours you choose to the screen.
• Today stands out in the month grid.
• A link in an event's location opens in the browser.
@@ -0,0 +1 @@
The store listing is called "Calendula: Calendar" again. Nothing in the app changed.
@@ -0,0 +1,3 @@
Fixed
• Edits, moves and deletions show up straight away on every phone. On some phones, Samsung among them, a changed event stayed put until you restarted the app.
• The home-screen widgets load only the events they show. This should help widgets stuck on "loading" on slower phones.
@@ -0,0 +1,2 @@
Fixed
• Reopening the app the next morning shows the right day. The month view used to mark yesterday as today and keep the day you picked the evening before selected. All views now move on to the new day.
+1 -1
View File
@@ -1 +1 @@
Calendula Calendula: Calendar
@@ -0,0 +1,7 @@
2.22.0 y 2.22.1 no llegaron a Google Play, así que esta es la primera versión 2.22 allí. La app es la misma que 2.22.1.
• Día, Semana y Mes se deslizan con un paginador que sigue tu dedo.
• Vista de varios días: muestra un número configurable de días uno junto a otro.
• Horas visibles: un rango de horas elegido llena la pantalla.
• Hoy destaca en la cuadrícula del mes.
• Un enlace en la ubicación de un evento se abre en el navegador.
@@ -0,0 +1 @@
La ficha de la tienda vuelve a llamarse «Calendula: Calendario». La app no cambia.
@@ -0,0 +1,3 @@
Corregido
• Los eventos editados, movidos o eliminados se actualizan al momento en cualquier móvil. En algunos, como los Samsung, un evento cambiado no se actualizaba hasta reiniciar la app.
• Los widgets de la pantalla de inicio solo cargan los eventos que muestran. Esto debería ayudar a los widgets que se quedaban en «cargando» en móviles más lentos.
@@ -0,0 +1,2 @@
Corregido
• Al abrir la app a la mañana siguiente, se muestra el día correcto. La vista de mes marcaba ayer como hoy y dejaba seleccionado el día que elegiste la noche anterior. Ahora todas las vistas pasan al nuevo día.
+1 -1
View File
@@ -1 +1 @@
Calendula Calendula: Calendario
@@ -0,0 +1,7 @@
Les versions 2.22.0 et 2.22.1 ne sont pas arrivées sur Google Play ; voici donc la première version 2.22 sur Play. L'app est identique à la 2.22.1.
• Jour, Semaine et Mois défilent avec un pager qui suit le doigt.
• Vue multi-jours : affiche un nombre de jours réglable, côte à côte.
• Heures visibles : une plage d'heures choisie remplit l'écran.
• Aujourd'hui ressort dans la grille du mois.
• Un lien dans le lieu d'un événement s'ouvre dans le navigateur.
@@ -0,0 +1 @@
La fiche du store s'appelle de nouveau « Calendula: Calendrier ». L'app ne change pas.
@@ -0,0 +1,3 @@
Corrigé
• Les événements modifiés, déplacés ou supprimés s'affichent tout de suite sur tous les téléphones. Sur certains, dont les Samsung, un événement modifié ne changeait qu'après un redémarrage de l'app.
• Les widgets de l'écran d'accueil ne chargent plus que les événements qu'ils affichent, ce qui devrait aider ceux qui restaient bloqués sur « chargement » sur les téléphones lents.
@@ -0,0 +1,2 @@
Corrigé
• En ouvrant l'app le lendemain matin, vous voyez le bon jour. La vue mois marquait la veille comme aujourd'hui et gardait sélectionné le jour choisi la veille au soir. Toutes les vues passent maintenant au nouveau jour.
+1 -1
View File
@@ -1 +1 @@
Calendula Calendula: Calendrier
@@ -0,0 +1,7 @@
A 2.22.0 és a 2.22.1 nem jutott el a Google Playre, így ez az első 2.22-es verzió ott. Az alkalmazás ugyanaz, mint a 2.22.1.
• A Nap, Hét és Hónap nézet ujjkövető lapozóval lapozható.
• A többnapos nézet beállítható számú napot jelenít meg egymás mellett.
• Látható órák: a kiválasztott óratartomány kitölti a képernyőt.
• A mai nap kiemelkedik a havi rácsban.
• Az esemény helyszínében lévő link a böngészőben nyílik meg.
@@ -0,0 +1 @@
Az áruházi oldal neve ismét „Calendula: Naptár”. Az alkalmazás nem változott.
@@ -0,0 +1,3 @@
Javítva
• A szerkesztett, áthelyezett és törölt események most minden telefonon azonnal megjelennek. Egyes készülékeken, köztük a Samsungokon, a módosított esemény az alkalmazás újraindításáig változatlan maradt.
• A kezdőképernyő-widgetek csak a megjelenített eseményeket töltik be. Ez segíthet azokon a widgeteken, amelyek lassabb telefonokon a „betöltés” állapotban ragadtak.
@@ -0,0 +1,2 @@
Javítva
• Ha másnap reggel megnyitod az alkalmazást, a helyes napot mutatja. A havi nézet eddig a tegnapot jelölte mai napként, és kijelölve hagyta az előző este választott napot. Most minden nézet átvált az új napra.
+1 -1
View File
@@ -1 +1 @@
Calendula Calendula: Naptár
@@ -0,0 +1,7 @@
Le versioni 2.22.0 e 2.22.1 non sono arrivate su Google Play, quindi questa è la prima versione 2.22 lì. L'app è la stessa della 2.22.1.
• Giorno, Settimana e Mese scorrono con un pager che segue il dito.
• Vista multi-giorno: mostra un numero configurabile di giorni affiancati.
• Ore visibili: un intervallo di ore scelto riempie lo schermo.
• Oggi risalta nella griglia del mese.
• Un link nel luogo di un evento si apre nel browser.
@@ -0,0 +1 @@
La scheda dello store si chiama di nuovo «Calendula: Calendario». L'app non cambia.
@@ -0,0 +1,3 @@
Corretto
• Gli eventi modificati, spostati o eliminati si aggiornano subito su ogni telefono. Su alcuni, tra cui i Samsung, un evento modificato restava invariato fino al riavvio dell'app.
• I widget della schermata Home caricano solo gli eventi che mostrano. Dovrebbe aiutare i widget che restavano su «caricamento» sui telefoni più lenti.
@@ -0,0 +1,2 @@
Corretto
• Aprendo l'app la mattina dopo, viene mostrato il giorno giusto. La vista mese segnava ieri come oggi e lasciava selezionato il giorno scelto la sera prima. Ora tutte le viste passano al nuovo giorno.
+1 -1
View File
@@ -1 +1 @@
Calendula Calendula: Calendario
@@ -0,0 +1,7 @@
2.22.0と2.22.1はGoogle Playに配信されなかったため、これがGoogle Playでの最初の2.22です。アプリの内容は2.22.1と同じです。
• 日・週・月ビューが指に追従するページャーでめくれます。
• マルチデイビューで、設定可能な日数を横に並べて表示します。
• 表示時間帯: 選んだ時間の範囲が画面いっぱいに表示されます。
• 月表示で今日が目立ちます。
• 予定の場所にあるリンクはブラウザで開きます。
@@ -0,0 +1 @@
ストアの掲載名が再び「Calendula: カレンダー」になりました。アプリ自体に変更はありません。
@@ -0,0 +1,3 @@
修正
• 予定の編集・移動・削除が、どの端末でもすぐに反映されるようになりました。Samsung など一部の端末では、アプリを再起動するまで変更前の予定が表示されたままでした。
• ホーム画面のウィジェットは、表示する予定だけを読み込むようになりました。動作の遅い端末で「読み込み中」のまま止まるウィジェットが改善されるはずです。
@@ -0,0 +1,2 @@
修正
• 翌朝アプリを開くと、正しい日が表示されるようになりました。これまで月表示では前日が今日として表示され、前の晩に選んだ日も選択されたままでした。今はすべての表示が新しい日に切り替わります。
+1 -1
View File
@@ -1 +1 @@
Calendula Calendula: カレンダー
@@ -0,0 +1,7 @@
2.22.0 en 2.22.1 zijn nooit op Google Play verschenen, dus dit is daar de eerste 2.22-versie. De app is gelijk aan 2.22.1.
• Dag, Week en Maand blader je door met een pager die je vinger volgt.
• Een meerdaagse weergave toont een instelbaar aantal dagen naast elkaar.
• Zichtbare uren: een gekozen tijdsbereik vult het scherm.
• Vandaag valt op in het maandraster.
• Een link in de locatie van een afspraak opent in de browser.
@@ -0,0 +1 @@
De winkelvermelding heet weer "Calendula: Agenda". Aan de app is niets veranderd.
@@ -0,0 +1,3 @@
Opgelost
• Bewerkte, verplaatste en verwijderde afspraken zijn nu op elke telefoon meteen zichtbaar. Op sommige toestellen, waaronder Samsung, bleef een gewijzigde afspraak staan tot je de app opnieuw startte.
• De widgets op het startscherm laden alleen nog de afspraken die ze tonen. Dat zou widgets moeten helpen die op tragere telefoons op "laden" bleven hangen.
@@ -0,0 +1,2 @@
Opgelost
• Open je de app de volgende ochtend, dan zie je de juiste dag. De maandweergave markeerde gisteren als vandaag en hield de dag die je de avond ervoor koos geselecteerd. Alle weergaven gaan nu mee naar de nieuwe dag.
@@ -0,0 +1 @@
Calendula: Agenda
-1
View File
@@ -1 +0,0 @@
Calendula
@@ -0,0 +1,7 @@
Wersje 2.22.0 i 2.22.1 nie trafiły do Google Play, więc to pierwsza wersja 2.22 w tym sklepie. Aplikacja jest taka sama jak 2.22.1.
• Dzień, Tydzień i Miesiąc przewijają się pagerem, który podąża za palcem.
• Widok wielodniowy pokazuje ustawialną liczbę dni obok siebie.
• Widoczne godziny: wybrany zakres godzin wypełnia ekran.
• Dziś wyróżnia się w siatce miesiąca.
• Link w miejscu wydarzenia otwiera się w przeglądarce.
@@ -0,0 +1 @@
Strona w sklepie znów nazywa się „Calendula: Kalendarz”. Aplikacja się nie zmieniła.
@@ -0,0 +1,3 @@
Poprawki
• Edytowane, przeniesione i usunięte wydarzenia od razu pokazują się poprawnie na każdym telefonie. Na niektórych, m.in. Samsungach, zmienione wydarzenie nie zmieniało się aż do ponownego uruchomienia aplikacji.
• Widżety na ekranie głównym wczytują tylko wydarzenia, które pokazują. To powinno pomóc widżetom, które na wolniejszych telefonach zostawały na „wczytywanie”.
@@ -0,0 +1,2 @@
Poprawki
• Po otwarciu aplikacji następnego ranka widać właściwy dzień. Widok miesiąca oznaczał wczoraj jako dziś i zostawiał zaznaczony dzień wybrany poprzedniego wieczoru. Teraz wszystkie widoki przechodzą na nowy dzień.
+1 -1
View File
@@ -1 +1 @@
Calendula Calendula: Kalendarz
@@ -0,0 +1,7 @@
As versões 2.22.0 e 2.22.1 não chegaram ao Google Play, então esta é a primeira versão 2.22 lá. O app é o mesmo da 2.22.1.
• Dia, Semana e Mês avançam com um pager que acompanha o dedo.
• Visualização de vários dias: mostra um número configurável de dias lado a lado.
• Horas visíveis: um intervalo de horas escolhido preenche a tela.
• Hoje se destaca na grade do mês.
• Um link no local de um evento abre no navegador.
@@ -0,0 +1 @@
A página da loja voltou a se chamar "Calendula: Calendário". O app não mudou.
@@ -0,0 +1,3 @@
Corrigido
• Eventos editados, movidos ou excluídos aparecem atualizados na hora em qualquer celular. Em alguns aparelhos, incluindo Samsung, um evento alterado só mudava depois de reiniciar o app.
• Os widgets da tela inicial carregam apenas os eventos que mostram. Isso deve ajudar widgets que ficavam em "carregando" em celulares mais lentos.
@@ -0,0 +1,2 @@
Corrigido
• Ao abrir o app na manhã seguinte, aparece o dia certo. A visualização do mês marcava ontem como hoje e deixava selecionado o dia escolhido na noite anterior. Agora todas as visualizações passam para o novo dia.
+1 -1
View File
@@ -1 +1 @@
Calendula Calendula: Calendário
@@ -0,0 +1,7 @@
As versões 2.22.0 e 2.22.1 não chegaram ao Google Play, por isso esta é a primeira versão 2.22 lá. A app é a mesma da 2.22.1.
• Dia, Semana e Mês deslizam com um pager que acompanha o dedo.
• Vista de vários dias: mostra um número configurável de dias lado a lado.
• Horas visíveis: um intervalo de horas escolhido preenche o ecrã.
• Hoje destaca-se na grelha do mês.
• Uma ligação no local de um evento abre no navegador.
@@ -0,0 +1 @@
A página da loja volta a chamar-se "Calendula: Calendário". A app não mudou.
@@ -0,0 +1,3 @@
Corrigido
• Os eventos editados, movidos ou eliminados aparecem logo atualizados em qualquer telemóvel. Em alguns, incluindo Samsung, um evento alterado só mudava depois de reiniciar a app.
• Os widgets do ecrã inicial carregam apenas os eventos que mostram. Isto deve ajudar os widgets que ficavam em «a carregar» em telemóveis mais lentos.

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