Compare commits

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

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

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

The widget is unchanged and still starts at today.

Closes #367

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

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

Closes #373

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

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

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

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

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

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

Closes #363
Closes #364

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

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

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

### Why

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

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

### Checklist

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

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

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

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

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

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

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

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

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

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

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

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/366
2026-10-05 13:33:59 +02:00
106 changed files with 3568 additions and 801 deletions
+16 -1
View File
@@ -37,9 +37,24 @@ jobs:
- name: Reproducible-release invariant
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
# 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
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
# direct-download channel — the tag is an output of the pipeline, not its
# 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
# 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
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: |
set -e
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
echo "Resolved version $VERSION (code $VERSION_CODE)"
INFO=$(bash scripts/version_info.sh)
echo "$INFO"
echo "$INFO" >> "$GITHUB_OUTPUT"
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
CHANNEL=$(echo "$INFO" | sed -n 's/^channel=//p')
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "Manual dispatch — re-sign path, not a release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# A tag for this version already existing means the release shipped on
# an earlier push; do nothing. Absent => this merge cuts the release.
#
# Anything other than a clean 200/404 is treated as fatal rather than
# as "no tag". A Codeberg outage or a network blip would otherwise
# read as absent and re-cut a release that has already shipped —
# republishing to F-Droid and Play. Failing here is recoverable; a
# duplicate release is not.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
case "$STATUS" in
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
# Backstop for CI's guard: betas ship from release/* via beta.yaml.
if [ "$CHANNEL" != "stable" ]; then
echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2
exit 1
fi
# Tags are read from Codeberg, the canonical forge: a tag minted here is
# wiped by the next mirror sync until Codeberg has it. A lookup error is
# fatal, since guessing "no tag" would re-cut a shipped release.
GATE=$(bash scripts/release_gate.sh)
echo "is_release=${GATE#cut=}" >> "$GITHUB_OUTPUT"
# 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
@@ -124,65 +98,13 @@ jobs:
with:
submodules: recursive
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Android build environment
uses: ./.gitea/actions/android-env
- 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
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.
# The committed versionName is the source of truth; pin the derived code.
- name: Pin versionCode to versionName
if: env.IS_RELEASE == 'true'
run: |
set -e
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
grep -E 'versionName|versionCode' app/build.gradle.kts
run: bash scripts/version_info.sh --pin
# Test the exact commit being shipped (only on a real release).
- name: Unit tests
@@ -195,15 +117,7 @@ jobs:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
run: |
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
run: bash scripts/write_keystore.sh
- name: Build release APK
if: env.IS_RELEASE == 'true'
@@ -312,6 +226,7 @@ jobs:
# 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
# failure before here leaves no tag, so re-running the workflow retries).
# Also attaches the R8 mapping, best-effort.
- name: Create tag + Gitea release
if: env.IS_RELEASE == 'true'
env:
@@ -320,74 +235,10 @@ jobs:
SHA: ${{ github.sha }}
run: |
set -e
TAG="v$VERSION"
# Notes = this version's CHANGELOG section.
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { 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"
bash scripts/release_notes.sh "$VERSION" > release-notes.md
TAG="v$VERSION" PRERELEASE=false NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/release/mapping.txt \
bash scripts/publish_gitea_release.sh
# 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 +
@@ -406,84 +257,10 @@ jobs:
SHA: ${{ github.sha }}
run: |
set -e
if [ -z "${TOKEN:-}" ]; then
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
exit 0
fi
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."
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
TAG="v$VERSION" PRERELEASE=false NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/release/app-release.apk \
bash scripts/publish_codeberg_release.sh
# 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
+11 -2
View File
@@ -1,8 +1,17 @@
name: Renovate
on:
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
# Every merge to main. Mirror syncs from Codeberg fire push events here (the
# 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:
- cron: '0 5 * * 1'
# Manual run for an on-demand sweep from the Actions tab.
+24
View File
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Long press an event to select it.** In the day, 3-day and week views, a long press lifts the event and selects it. Keep your finger down and move to drag it straight away. A selected event has handles at its top and bottom: drag them to change when it starts or ends, in 15-minute steps and across midnight if you want. The bar at the top edits, duplicates, shares or deletes it, and tapping elsewhere or going back deselects it. Read-only events can be selected but not moved or resized ([#141]).
- **Scroll back to earlier days in the agenda.** Scroll up past today to see earlier days' events, loaded a month at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today ([#367]).
### Fixed
- **The agenda always starts today.** Coming from the month or day view, the agenda used to start at the day you had open there, so "Next 30 days" could show the 1st to the 30th of the month. It now opens on today with your chosen range. Backing out of a day you opened from the agenda still returns you to where you were ([#367]).
- **Multi-day events dim on the days already behind you.** With past events dimmed, an event running over several days stayed at full strength on its earlier days until it ended. Those days now dim in the agenda, month, week and day views ([#367]).
## [2.22.5] — 2026-10-07
### Fixed
- **Reopening the app the next morning shows the right day.** The month view used to mark yesterday as today and keep the day you picked the evening before selected, so you had to tap today to see its events. Every view now moves on to the new day at midnight and when you come back to the app. A view you browsed to another date stays where it is ([#373]).
## [2.22.4] — 2026-10-07
### Fixed
- **Edits and deletions show up straight away on every phone.** On some phones, Samsung among them, an edited, moved or deleted event stayed where it was until the app was restarted, so a second edit could act on an event that had already changed. Calendula now refreshes its views itself after every change, and again when you come back to the app ([#364]).
- **The home-screen widgets do less work per update.** The month widget loads the months around the one it shows instead of more than two years of events, and the agenda widget only its chosen range. This should help widgets that stayed on "loading" on slower phones ([#363]).
## [2.22.3] — 2026-10-02
### Fixed
@@ -1835,3 +1854,8 @@ automatically, with zero telemetry and no internet permission.
[#357]: https://codeberg.org/jlmakiola/calendula/issues/357
[#359]: https://codeberg.org/jlmakiola/calendula/issues/359
[#361]: https://codeberg.org/jlmakiola/calendula/issues/361
[#363]: https://codeberg.org/jlmakiola/calendula/issues/363
[#364]: https://codeberg.org/jlmakiola/calendula/issues/364
[#367]: https://codeberg.org/jlmakiola/calendula/issues/367
[#373]: https://codeberg.org/jlmakiola/calendula/issues/373
[#141]: https://codeberg.org/jlmakiola/calendula/issues/141
+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),
or add it by hand: *Add App*, paste `https://codeberg.org/jlmakiola/calendula`,
then *Add*. Obtainium then watches the releases and tells you when a new one
is out.
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
+8 -4
View File
@@ -26,10 +26,14 @@ android {
// These committed values ARE the source of truth for a release: merging
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 22203
versionName = "2.22.3"
// release itself. A versionName of X.Y.Z-beta.N pushed to a release/*
// branch instead cuts a Codeberg-only pre-release (beta.yaml).
// versionCode is derived from versionName by scripts/version_info.sh
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md.
versionCode = 2230002
versionName = "2.23.0-beta.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -26,6 +26,7 @@ import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.is24Hour
import de.jeanlucmakiola.calendula.domain.EventForm
@@ -56,6 +57,7 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
/** A prefilled create form from an external launch, with the source it came from. */
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
class MainActivity : AppCompatActivity() {
@Inject lateinit var calendarRepository: CalendarRepository
// 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
// 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() {
super.onResume()
// 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
// normal, un-cancelled event, so it must survive the filter — a bare
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
"${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}",
// DELETED = 0: a provider that soft-deletes may keep the row's
// instances until a purge (#364).
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
"${CalendarContract.Events.DELETED} = 0",
null,
CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
@@ -15,6 +15,9 @@ import kotlin.time.Instant
interface CalendarRepository {
fun calendars(): Flow<List<CalendarSource>>
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
/** Re-query every open [calendars] / [instances] flow. */
fun refresh()
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.ParsedIcsEvent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine
@@ -51,21 +52,37 @@ class CalendarRepositoryImpl @Inject constructor(
private suspend fun allDayReminderTimeMinutes(): Int =
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>(
replay = 0,
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]).
*/
private val generation = AtomicLong(0L)
init {
dataSource.registerChangeListener {
generation.incrementAndGet()
ticks.tryEmit(Unit)
dataSource.registerChangeListener(::refresh)
}
// 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,
color: Int,
description: String?,
): Long = withContext(io) {
): Long = write {
dataSource.createLocalCalendar(displayName, color, description)
}
@@ -190,13 +207,13 @@ class CalendarRepositoryImpl @Inject constructor(
displayName: String,
color: Int,
description: String?,
) = withContext(io) { dataSource.updateCalendar(id, displayName, color, description) }
) = write { dataSource.updateCalendar(id, displayName, color, description) }
override suspend fun deleteCalendar(id: Long) =
withContext(io) { dataSource.deleteCalendar(id) }
write { dataSource.deleteCalendar(id) }
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
withContext(io) {
write {
if (dataSource.canWriteCalendars()) {
ids.forEach { dataSource.setCalendarVisible(it, visible) }
// Nothing of ours is left waiting for the provider once the
@@ -217,7 +234,7 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun importEvents(
targetCalendarId: Long,
events: List<ParsedIcsEvent>,
): IcsImportSummary = withContext(io) {
): IcsImportSummary = write {
val existing = dataSource.existingUids(targetCalendarId)
// Both are per-calendar, not per-event: looking them up once keeps a
// 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())
}
@@ -276,11 +293,11 @@ class CalendarRepositoryImpl @Inject constructor(
eventId: Long,
original: EventForm,
updated: EventForm,
) = withContext(io) {
) = write {
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
}
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
override suspend fun deleteEvent(eventId: Long) = write {
dataSource.deleteEvent(eventId)
}
@@ -289,7 +306,7 @@ class CalendarRepositoryImpl @Inject constructor(
targetCalendarId: Long,
original: EventForm,
updated: EventForm,
): Long = withContext(io) {
): Long = write {
dataSource.moveEvent(
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
)
@@ -300,7 +317,7 @@ class CalendarRepositoryImpl @Inject constructor(
beginMillis: Long,
original: EventForm,
form: EventForm,
): Long = withContext(io) {
): Long = write {
dataSource.updateOccurrence(
eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
)
@@ -311,20 +328,20 @@ class CalendarRepositoryImpl @Inject constructor(
beginMillis: Long,
original: EventForm,
updated: EventForm,
): Long = withContext(io) {
): Long = write {
dataSource.updateEventFromOccurrence(
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)
}
override suspend fun deleteEventFromOccurrence(
eventId: Long,
beginMillis: Long,
) = withContext(io) {
) = write {
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.reminders.ProviderReminderInstanceSource
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.Dispatchers
import javax.inject.Singleton
@@ -61,6 +63,12 @@ abstract class DataBindModule {
abstract fun bindSpecialDatesCalendarSpec(
impl: AndroidSpecialDatesCalendarSpec,
): SpecialDatesCalendarSpec
@Binds
@Singleton
abstract fun bindTodaySource(
impl: AndroidTodaySource,
): TodaySource
}
@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
@@ -265,3 +265,20 @@ fun EventForm.problems(): Set<EventFormProblem> = buildSet {
add(EventFormProblem.RecurrenceEndsBeforeStart)
}
}
/**
* A create form seeded from this occurrence, to save a copy as a new,
* independent event (#52). The recurrence is dropped — a duplicate is a single
* event. The source calendar is kept only when [canModify], otherwise the id is
* cleared so the create resolves to a writable calendar, which lets a read-only
* event be copied into an editable one. [instance]'s own times carry over.
*/
fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
toEditForm(
beginMillis = instance.start.toEpochMilliseconds(),
endMillis = instance.end.toEpochMilliseconds(),
zone = zone,
).copy(
rrule = null,
calendarId = instance.calendarId.takeIf { canModify },
)
@@ -51,3 +51,13 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
val span = end.toInstant(zone) - start.toInstant(zone)
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
}
/** The form with new start and end, each in the form's own zone — a resize. All-day is untouched. */
fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm {
if (isAllDay) return this
val zone = resolvedZone(deviceZone)
return copy(
start = newStart.toLocalDateTime(zone),
end = newEnd.toLocalDateTime(zone),
)
}
@@ -55,7 +55,11 @@ import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBackExit
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventActionsViewModel
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
import de.jeanlucmakiola.calendula.ui.common.EventSelectionHost
import de.jeanlucmakiola.calendula.ui.common.eventActionFailureMessage
import de.jeanlucmakiola.calendula.ui.common.rememberFabSelection
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
@@ -146,14 +150,27 @@ fun CalendarHost(
mutableStateOf(listOf(defaultView))
}
var navigationVersion by remember { mutableIntStateOf(0) }
// True after backing out of a day opened from the view now shown, so the
// agenda returns to where it was instead of today (#367).
var returnedFromDay by remember { mutableStateOf(false) }
val view = viewStack.last()
// The date every view opens on and carries along (#184); tapping a day
// anywhere focuses it and opens the day view there.
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) }
// A focus still on yesterday follows the date over, so the next view opened
// doesn't land on the old day (#373); one moved elsewhere stays put.
val today = viewModel.today.collectAsStateWithLifecycle().value
var focusToday by rememberSaveable { mutableStateOf(today.toString()) }
LaunchedEffect(today) {
val old = LocalDate.parse(focusToday)
if (old != today && focus.date == old) focus.date = today
focusToday = today.toString()
}
val switchScope = rememberCoroutineScope()
val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move ->
navigationVersion++
returnedFromDay = false
viewStack = move(viewStack)
}
val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } }
@@ -313,7 +330,10 @@ fun CalendarHost(
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
// view so backing out returns there (then home), not to the default.
LaunchedEffect(widgetNavRequest) {
if (widgetNavRequest != null) navigationVersion++
if (widgetNavRequest != null) {
navigationVersion++
returnedFromDay = false
}
when (val req = widgetNavRequest) {
is WidgetNavRequest.OpenDate -> {
// Drill the day view in over the widget's view: drop any overlay
@@ -370,6 +390,9 @@ fun CalendarHost(
// survives view switches; the calendar surfaces read it out of LocalEventMove.
val reschedule: RescheduleViewModel = hiltViewModel()
val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle()
val eventActions: EventActionsViewModel = hiltViewModel()
val moveOutcome by reschedule.outcome.collectAsStateWithLifecycle()
val selectionFailure by eventActions.failure.collectAsStateWithLifecycle()
val onEditEvent: (EventInstance) -> Unit = { event ->
val key = longArrayOf(
event.eventId,
@@ -415,11 +438,14 @@ fun CalendarHost(
if (reduceMotion) {
BackHandler(enabled = viewBackEnabled) {
navigationVersion++
returnedFromDay = viewStack.last() == CalendarView.Day
viewStack = viewStack.dropLast(1)
}
} else {
PredictiveBackHandler(enabled = viewBackEnabled) { events ->
val previous = viewStack[viewStack.size - 2]
// Set before the seek, which already composes the previous view.
returnedFromDay = viewStack.last() == CalendarView.Day
try {
events.collect { viewTransition.seekTo(it.progress, targetState = previous) }
navigationVersion++
@@ -508,7 +534,25 @@ fun CalendarHost(
showTodayButton = todayInToolbar,
)
},
floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) },
floatingActionButton = {
// While an event is selected the FAB is its action bar's toggle (#141).
val fabSelection = rememberFabSelection(
host = chromeHost,
viewModel = eventActions,
onEdit = onEditEvent,
onDuplicate = { form ->
// A fresh event, like a duplicate from the detail screen (#52).
importFormSource = ImportSource.Insert
importForm = form
},
chipShown = moveOutcome != null || selectionFailure != null,
onDismissChip = {
reschedule.consumeOutcome()
eventActions.consumeFailure()
},
)
CalendarFab(chromeHost, todayInToolbar, fabSelection)
},
) { innerPadding ->
SharedTransitionLayout(
Modifier
@@ -551,6 +595,7 @@ fun CalendarHost(
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
restorePosition = returnedFromDay,
)
}
}
@@ -562,7 +607,15 @@ fun CalendarHost(
// Scope prompt + confirmation/undo snackbar for a dropped event, declared
// right after the calendar views so later overlays cover it.
EventMoveHost(reschedule, modifier = Modifier.fillMaxSize())
EventMoveHost(
reschedule,
modifier = Modifier.fillMaxSize(),
actionFailure = eventActionFailureMessage(selectionFailure),
onActionFailureShown = eventActions::consumeFailure,
)
// The delete prompt and failure chip of a selected event's actions (#141).
EventSelectionHost(host = chromeHost, viewModel = eventActions)
// Search overlay — below detail/edit in the Box so a tapped result's
// detail screen draws on top, and closing it returns to the results.
@@ -4,12 +4,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
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.IMPLEMENTED_VIEWS
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.LocalDate
import javax.inject.Inject
/**
@@ -21,8 +24,16 @@ import javax.inject.Inject
@HiltViewModel
class CalendarHostViewModel @Inject constructor(
prefs: SettingsPrefs,
todaySource: TodaySource,
) : 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(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
@@ -5,7 +5,6 @@ import de.jeanlucmakiola.calendula.ui.common.ScreenActions
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
import de.jeanlucmakiola.calendula.ui.common.ScreenChrome
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus
import kotlinx.coroutines.flow.drop
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
@@ -38,7 +37,16 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableIntState
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -53,7 +61,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.isPastOn
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
@@ -82,11 +90,23 @@ fun AgendaScreen(
onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
modifier: Modifier = Modifier,
restorePosition: Boolean = false,
viewModel: AgendaViewModel = hiltViewModel(),
) {
// Opens on the focused date and hands back the day scrolled to the top.
// Opens on today, not on the day another view was on (#367). Backing out of
// a day opened from here, or a recreated screen, returns to the day last at
// the top. The top day still goes back to the shared focus.
val focus = LocalViewFocus.current
EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) }
val opened = rememberSaveable { mutableStateOf(false) }
val restoreTop = remember(viewModel) {
if (restorePosition || opened.value) {
viewModel.lastTopDate
} else {
viewModel.goToToday()
null
}
}
SideEffect { opened.value = true }
val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
@@ -106,20 +126,19 @@ fun AgendaScreen(
// list scroll, not an anchor change — "today" has to bring the list back to
// the top as well, and stay offered while it is scrolled away (#305).
val listState = rememberLazyListState()
// Past days load above the anchor (#367), so the anchor's row counts as the
// start of the list.
val anchorIndex = remember { mutableIntStateOf(0) }
val scrolledAway by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
listState.firstVisibleItemIndex != anchorIndex.intValue ||
listState.firstVisibleItemScrollOffset > 0
}
}
// A new window is rendered from its first day, so the list belongs at the
// top. Keyed on the anchor the days arrived with, not the one just
// requested: scrolling before the new rows compose leaves the list keyed to
// a row that reappears further down, and it follows it there.
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
val jumpToToday: () -> Unit = {
// Off today, moving the anchor is what resets the list (above); on today
// Off today, moving the anchor is what repositions the list; on today
// the window doesn't change, so the scroll back is the whole action.
if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
if (isOnToday) scope.launch { listState.animateScrollToItem(anchorIndex.intValue) }
viewModel.goToToday()
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
}
@@ -188,11 +207,17 @@ fun AgendaScreen(
pastDisplay = pastDisplay,
showToday = showToday,
listState = listState,
anchorIndex = anchorIndex,
initialTopDate = restoreTop,
onLoadEarlier = viewModel::loadEarlier,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onTopDay = if (successState?.anchor == anchor) {
{ focus?.date = it }
{
focus?.date = it
viewModel.rememberTopDay(it)
}
} else null,
modifier = Modifier
.weight(1f)
@@ -294,6 +319,9 @@ internal fun AgendaContent(
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
anchorIndex: MutableIntState = remember { mutableIntStateOf(0) },
initialTopDate: LocalDate? = null,
onLoadEarlier: () -> Unit = {},
onTopDay: ((LocalDate) -> Unit)? = null,
) {
when (state) {
@@ -308,28 +336,34 @@ internal fun AgendaContent(
// away (or fade) as they end while the screen stays open.
val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
state.days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(now) }
val remaining = day.events.filterNot { it.isPastOn(day.date, now, state.zone) }
if (remaining.isEmpty()) null else day.copy(events = remaining)
}
} else {
state.days
}
// Anchor today with a "nothing left today" placeholder — but only when
// the window actually starts on today; a jumped-to date has no today in
// it, so anchoring there would be misleading (#35).
// the agenda actually opens on today; a jumped-to date has no today
// in it, so anchoring there would be misleading (#35).
val days = anchorTodayIfMissing(
days = filtered,
today = state.today,
enabled = showToday && state.anchor == state.today,
)
if (days.isEmpty()) {
// Past days alone don't count as anything coming up.
if (days.none { it.date >= state.anchor }) {
// Nothing to scroll: drop the position a previous list left, so
// the today FAB doesn't linger over an empty screen.
LaunchedEffect(Unit) { listState.scrollToItem(0) }
SideEffect { anchorIndex.intValue = 0 }
AgendaEmpty(modifier)
} else {
AgendaList(
days = days,
anchor = state.anchor,
anchorIndex = anchorIndex,
initialTopDate = initialTopDate,
onLoadEarlier = onLoadEarlier,
today = state.today,
zone = state.zone,
listState = listState,
@@ -349,6 +383,10 @@ internal fun AgendaContent(
@Composable
private fun AgendaList(
days: List<AgendaDay>,
anchor: LocalDate,
anchorIndex: MutableIntState,
initialTopDate: LocalDate?,
onLoadEarlier: () -> Unit,
today: LocalDate,
zone: TimeZone,
listState: LazyListState,
@@ -359,19 +397,49 @@ private fun AgendaList(
onTopDay: ((LocalDate) -> Unit)?,
modifier: Modifier = Modifier,
) {
val itemDates = agendaItemDates(days)
val anchorRow = itemDates.indexOfFirst { it >= anchor }.coerceAtLeast(0)
SideEffect { anchorIndex.intValue = anchorRow }
// A new anchor opens the list on its row from the first frame, with past
// days above. Keyed on the anchor the days arrived with, not the one just
// requested: scrolling before the new rows compose leaves the list keyed to
// a row that reappears further down, and it follows it there. The first run
// may restore a remembered day instead.
val pendingRestore = remember { arrayOf(initialTopDate) }
DisposableEffect(anchor) {
val target = pendingRestore[0]
pendingRestore[0] = null
val index = target?.let { date -> itemDates.indexOfFirst { it >= date } }
?.takeIf { it >= 0 }
?: anchorRow
listState.requestScrollToItem(index)
onDispose {}
}
// Scrolling up near the first loaded day loads earlier days.
val currentLoadEarlier by rememberUpdatedState(onLoadEarlier)
val loadOnScrollUp = remember(listState) {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (available.y > 0 && listState.firstVisibleItemIndex < LOAD_EARLIER_WITHIN) {
currentLoadEarlier()
}
return Offset.Zero
}
}
}
if (onTopDay != null) {
val itemDates by rememberUpdatedState(agendaItemDates(days))
val currentItemDates by rememberUpdatedState(itemDates)
val currentOnTopDay by rememberUpdatedState(onTopDay)
// The day the list opened on is where the view already is; only a
// scroll moves it on.
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.drop(1)
.collect { index -> itemDates.getOrNull(index)?.let(currentOnTopDay) }
.collect { index -> currentItemDates.getOrNull(index)?.let(currentOnTopDay) }
}
}
LazyColumn(
modifier = modifier,
modifier = modifier.nestedScroll(loadOnScrollUp),
state = listState,
// Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
@@ -401,7 +469,7 @@ private fun AgendaList(
day = day.date,
zone = zone,
position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now),
dimmed = dimPast && event.isPastOn(day.date, now, zone),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
onClick = { onEventClick(event) },
)
@@ -412,6 +480,9 @@ private fun AgendaList(
}
}
/** How close to the first loaded row an upward scroll starts loading earlier days. */
private const val LOAD_EARLIER_WITHIN = 10
/** The day of each item [AgendaList] lays out for [days], in its order. */
internal fun agendaItemDates(days: List<AgendaDay>): List<LocalDate> =
days.flatMap { day ->
@@ -93,22 +93,21 @@ fun groupAgendaDays(
}
/**
* Ensure [today] surfaces as the first agenda day even when it carries no
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget
* Ensure [today] surfaces in the agenda even when it carries no (remaining)
* events, by inserting an empty-event [AgendaDay] in date order that the agenda
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and
* when today already has its own day in [days]. Keeps today anchored at the top
* so a glance tells today's events apart from a future day's (issue #35).
* when today already has its own day in [days]. Keeps today anchored so a glance
* tells today's events apart from a future day's (issue #35).
*/
fun anchorTodayIfMissing(
days: List<AgendaDay>,
today: LocalDate,
enabled: Boolean,
): List<AgendaDay> =
if (enabled && days.none { it.date == today }) {
listOf(AgendaDay(today, emptyList())) + days
} else {
days
}
): List<AgendaDay> {
if (!enabled || days.any { it.date == today }) return days
val at = days.indexOfFirst { it.date > today }.let { if (it < 0) days.size else it }
return days.take(at) + AgendaDay(today, emptyList()) + days.drop(at)
}
/**
* State for the Agenda view: a flat, forward-looking list of upcoming events
@@ -118,8 +117,10 @@ sealed interface AgendaUiState {
data object Loading : AgendaUiState
data class Failure(val reason: FailureReason) : AgendaUiState
data class Success(
/** First day of the loaded window (today, or a jumped-to date). */
/** The day the agenda opens on (today, or a jumped-to date). */
val anchor: LocalDate,
/** First loaded day, before [anchor] once past days are loaded (#367). */
val windowStart: LocalDate,
val today: LocalDate,
val days: List<AgendaDay>,
/** The range currently in effect — the saved default or a session override. */
@@ -8,6 +8,8 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
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.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
@@ -21,6 +23,8 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
@@ -28,10 +32,9 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
@@ -40,6 +43,7 @@ import javax.inject.Inject
class AgendaViewModel @Inject constructor(
private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
@@ -84,10 +88,34 @@ class AgendaViewModel @Inject constructor(
private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date
get() = currentDate()
private val _anchor = MutableStateFlow(todayDate)
val anchor: StateFlow<LocalDate> = _anchor
// The anchor and how many days before it are loaded, moved together so a
// jump never queries a window that pairs the new anchor with the old span.
private val _window = MutableStateFlow(AgendaWindow(todayDate, PAST_CHUNK_DAYS))
val anchor: StateFlow<LocalDate> = _window
.map { it.anchor }
.stateIn(viewModelScope, SharingStarted.Eagerly, todayDate)
/** The day last scrolled to the top, so a return to the agenda lands back on it. */
var lastTopDate: LocalDate? = null
private set
private var lastToday = todayDate
/**
* Today, kept current across midnight (#373). An agenda still starting on
* yesterday moves on to the new day; one jumped elsewhere stays put.
*/
private val today: StateFlow<LocalDate> = todaySource.today
.onEach { date ->
val old = lastToday
lastToday = date
if (old != date && _window.value.anchor == old) {
_window.value = _window.value.copy(anchor = date)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
// A transient, in-view override of the saved agenda range. Held in memory
// (not persisted), so it survives view switches and rotation within the
@@ -95,9 +123,17 @@ class AgendaViewModel @Inject constructor(
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
val state: StateFlow<AgendaUiState> =
combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart ->
combine(
_window,
agendaSettings,
_rangeOverride,
weekStart,
settingsPrefs.pastEventDisplay,
) { window, settings, override, weekStart, pastDisplay ->
AgendaParams(
anchor = anchor,
anchor = window.anchor,
// Hidden past events leave nothing to scroll back to (#367).
pastDays = if (pastDisplay == PastEventDisplay.HIDE) 0 else window.pastDays,
range = override ?: settings.range,
rangeIsOverride = override != null && override != settings.range,
weekStart = weekStart,
@@ -106,15 +142,16 @@ class AgendaViewModel @Inject constructor(
}
.flatMapLatest { params ->
val window = agendaRange(
params.anchor,
params.range.dayCount(params.anchor, params.weekStart) - 1,
params.windowStart,
params.pastDays + params.range.dayCount(params.anchor, params.weekStart) - 1,
zone,
)
combine(
repository.calendars(),
repository.instances(window),
) { calendars, instances ->
buildState(params, calendars, instances)
today,
) { calendars, instances, today ->
buildState(params, calendars, instances, today)
}
}
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
@@ -126,12 +163,30 @@ class AgendaViewModel @Inject constructor(
)
fun goToToday() {
_anchor.value = todayDate
goToDate(todayDate)
}
/** Jump the agenda window to start on a specific date (drawer jump-to-date). */
fun goToDate(date: LocalDate) {
_anchor.value = date
if (_window.value.anchor == date) return
_window.value = AgendaWindow(date, PAST_CHUNK_DAYS)
lastTopDate = null
}
/**
* Extend the window [PAST_CHUNK_DAYS] further back (#367). Ignored while the
* previous extension is still loading, so a scroll that keeps asking only
* moves one chunk at a time.
*/
fun loadEarlier() {
val loaded = (state.value as? AgendaUiState.Success)?.windowStart ?: return
val window = _window.value
if (loaded != window.start) return
_window.value = window.copy(pastDays = window.pastDays + PAST_CHUNK_DAYS)
}
fun rememberTopDay(date: LocalDate) {
lastTopDate = date
}
/** Temporarily override the agenda range for this session (the bottom-left pill). */
@@ -144,18 +199,26 @@ class AgendaViewModel @Inject constructor(
val showBar: Boolean,
)
private data class AgendaWindow(val anchor: LocalDate, val pastDays: Int) {
val start: LocalDate get() = anchor.minus(pastDays, DateTimeUnit.DAY)
}
private data class AgendaParams(
val anchor: LocalDate,
val pastDays: Int,
val range: AgendaRange,
val rangeIsOverride: Boolean,
val weekStart: DayOfWeek,
val showRangeBar: Boolean,
)
) {
val windowStart: LocalDate get() = anchor.minus(pastDays, DateTimeUnit.DAY)
}
private fun buildState(
params: AgendaParams,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
today: LocalDate,
): AgendaUiState {
calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) }
val anchor = params.anchor
@@ -163,10 +226,11 @@ class AgendaViewModel @Inject constructor(
params.range.dayCount(anchor, params.weekStart) - 1,
DateTimeUnit.DAY,
)
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
val days = groupAgendaDays(params.windowStart, rangeEnd, instances, zone)
return AgendaUiState.Success(
anchor = anchor,
today = todayDate,
windowStart = params.windowStart,
today = today,
days = days,
range = params.range,
rangeIsOverride = params.rangeIsOverride,
@@ -177,6 +241,9 @@ class AgendaViewModel @Inject constructor(
}
}
/** How many days each scroll back into the past loads (#367). */
internal const val PAST_CHUNK_DAYS = 30
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> {
val from = anchor.atStartOfDayIn(zone)
@@ -51,6 +51,7 @@ private fun sampleAgendaState(today: LocalDate, zone: TimeZone): AgendaUiState.S
val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY)
return AgendaUiState.Success(
anchor = today,
windowStart = today,
today = today,
days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone),
range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS),
@@ -1,5 +1,14 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
@@ -16,6 +25,9 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
@@ -24,6 +36,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
/**
@@ -53,12 +66,31 @@ class ScreenActions(
val onCreate: () -> Unit,
)
/**
* An event a long press left selected (#141), which the host answers with the
* action bar in place of the FAB. [dismiss] clears it on the screen that owns it.
*/
@Stable
class EventSelection(val event: EventInstance, val dismiss: () -> Unit)
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
@Stable
class ChromeHost {
var chrome by mutableStateOf<ScreenChrome?>(null)
private set
var selection by mutableStateOf<EventSelection?>(null)
internal set
/** Whether the selected event is being dragged or resized right now. */
var selectionBusy by mutableStateOf(false)
internal set
/** Whether the bar would cover the selected event, and so draws compact. */
var selectionCompact by mutableStateOf(false)
internal set
var actions: ScreenActions? = null
private set
@@ -80,8 +112,35 @@ fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
}
/** The top bar every calendar screen shares. */
@OptIn(ExperimentalMaterial3Api::class)
/** Hands the screen's selected [event] to the host, for as long as it stays selected. */
@Composable
fun PublishSelection(event: EventInstance?, busy: Boolean, compact: Boolean, onDismiss: () -> Unit) {
val slot = LocalChromeSlot.current ?: return
val currentDismiss by rememberUpdatedState(onDismiss)
val selection = remember(event) { event?.let { EventSelection(it) { currentDismiss() } } }
val host = slot.host
val current = slot.current
DisposableEffect(host, selection, current) {
if (current) host.selection = selection
onDispose {
if (host.selection === selection) host.selection = null
// Left mid-drag, nothing else would clear these for the other views.
host.selectionBusy = false
host.selectionCompact = false
}
}
if (current) {
SideEffect {
host.selectionBusy = busy && selection != null
host.selectionCompact = compact && selection != null
}
}
}
/**
* The top bar every calendar screen shares — or, while an event is selected,
* the selection's own bar in its place (#141).
*/
@Composable
fun CalendarTopBar(
host: ChromeHost,
@@ -91,6 +150,43 @@ fun CalendarTopBar(
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
) {
val effects = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
AnimatedContent(
targetState = host.selection,
contentKey = { it != null },
transitionSpec = {
(fadeIn(effects) + slideInVertically { -it / 4 })
.togetherWith(fadeOut(effects) + slideOutVertically { -it / 4 })
},
label = "top-bar",
) { selection ->
if (selection != null) {
SelectionTopBar(selection)
} else {
ViewTopBar(
host = host,
selectedView = selectedView,
quickSwitchViews = quickSwitchViews,
onCycleView = onCycleView,
onOpenDrawer = onOpenDrawer,
onOpenSearch = onOpenSearch,
showTodayButton = showTodayButton,
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ViewTopBar(
host: ChromeHost,
selectedView: CalendarView,
quickSwitchViews: List<CalendarView>,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
) {
val chrome = host.chrome
TopAppBar(
@@ -136,16 +232,26 @@ fun CalendarTopBar(
)
}
/** The FAB column every calendar screen shares. */
/**
* The FAB column every calendar screen shares. While an event is selected the
* FAB is [selection]'s toggle instead, and it steps aside mid-gesture.
*/
@Composable
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) {
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean, selection: FabSelection? = null) {
val chrome = host.chrome ?: return
CalendarFabColumn(
todayVisible = chrome.awayFromToday && !todayInToolbar,
todayText = chrome.todayText,
onToday = { host.actions?.onToday?.invoke() },
onCreate = { host.actions?.onCreate?.invoke() },
)
AnimatedVisibility(
visible = !host.selectionBusy,
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(),
) {
CalendarFabColumn(
todayVisible = chrome.awayFromToday && !todayInToolbar,
todayText = chrome.todayText,
onToday = { host.actions?.onToday?.invoke() },
onCreate = { host.actions?.onCreate?.invoke() },
selection = selection,
)
}
}
/**
@@ -1,11 +1,20 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -15,15 +24,38 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/** The standard FAB's size, shared by the selection bar that unrolls from it (#141). */
val FabSize = 56.dp
/** What the FAB is while an event is selected (#141): the toggle for its action bar. */
@Stable
class FabSelection(
val expanded: Boolean,
val onToggle: () -> Unit,
val bar: @Composable () -> Unit,
)
/**
* The FAB stack shared by the three calendar views: a persistent "+" to
* create an event, with the jump-to-today pill appearing above it whenever
* the view isn't anchored on today.
* the view isn't anchored on today. While an event is selected the same FAB,
* in the same spot, opens and folds the selection's action bar, which unrolls
* from it towards the start.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -32,13 +64,14 @@ fun CalendarFabColumn(
todayText: String,
onToday: () -> Unit,
onCreate: () -> Unit,
selection: FabSelection? = null,
) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
AnimatedVisibility(
visible = todayVisible,
visible = todayVisible && selection == null,
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
) {
@@ -48,11 +81,71 @@ fun CalendarFabColumn(
text = { Text(todayText) },
)
}
FloatingActionButton(onClick = onCreate) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.event_edit_new_title),
// Held past the selection clearing, so the bar has something to fold away.
val heldBar = remember { mutableStateOf<(@Composable () -> Unit)?>(null) }
if (selection != null && heldBar.value !== selection.bar) heldBar.value = selection.bar
// Held at the FAB's height so the taller bar can't shift the FAB.
Row(
modifier = Modifier.height(FabSize),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AnimatedVisibility(
visible = selection?.expanded == true,
enter = expandHorizontally(
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
expandFrom = Alignment.End,
) + fadeIn(),
exit = shrinkHorizontally(
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
shrinkTowards = Alignment.End,
) + fadeOut(),
) {
heldBar.value?.invoke()
}
val label = stringResource(
when {
selection == null -> R.string.event_edit_new_title
selection.expanded -> R.string.event_selection_hide
else -> R.string.event_selection_actions
},
)
FloatingActionButton(
onClick = selection?.onToggle ?: onCreate,
modifier = Modifier.semantics { contentDescription = label },
) {
FabGlyph(selecting = selection != null, expanded = selection?.expanded == true)
}
}
}
}
/** "+", or with an event selected a chevron that turns to open and fold the bar. */
@Composable
private fun FabGlyph(selecting: Boolean, expanded: Boolean) {
val spatial = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
val swap by animateFloatAsState(if (selecting) 1f else 0f, spatial, label = "fab-swap")
val turn by animateFloatAsState(if (expanded) 0f else 180f, spatial, label = "fab-turn")
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.graphicsLayer {
rotationZ = 90f * swap
scaleX = 1f - 0.5f * swap
scaleY = 1f - 0.5f * swap
alpha = (1f - swap).coerceIn(0f, 1f)
},
)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.graphicsLayer {
rotationZ = turn - 90f * (1f - swap)
scaleX = 0.5f + 0.5f * swap
scaleY = 0.5f + 0.5f * swap
alpha = swap.coerceIn(0f, 1f)
},
)
}
}
@@ -0,0 +1,170 @@
package de.jeanlucmakiola.calendula.ui.common
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
import de.jeanlucmakiola.floret.components.OptionCard
import kotlin.time.Clock
/**
* Serialise [detail] to a cache `.ics` and return a shareable content Uri for an
* `ACTION_SEND`, or null when the write fails. Exported as a one-off (see
* [toShareIcsEvent]). Blocking — call it off the main thread.
*/
fun IcsExporter.stageEventShare(detail: EventDetail): Uri? = runCatching {
val ics = IcsWriter().writeCalendar(
events = listOf(detail.toShareIcsEvent()),
dtStamp = Clock.System.now(),
)
stageShareFile(shareFileName(detail.instance.title), ics)
}.getOrNull()
/** Open the system chooser for a staged `.ics`; false when nothing could take it. */
fun Context.sendEventShare(uri: Uri, chooserTitle: String): Boolean = runCatching {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/calendar"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(send, chooserTitle))
}.isSuccess
/** Delete an event, or as much of its series as [scope] reaches from the occurrence at [beginMillis]. */
suspend fun CalendarRepository.deleteEvent(
eventId: Long,
beginMillis: Long,
scope: RecurringWriteScope,
) = when (scope) {
RecurringWriteScope.AllEvents -> deleteEvent(eventId)
RecurringWriteScope.ThisEvent -> deleteOccurrence(eventId, beginMillis)
RecurringWriteScope.ThisAndFollowing -> deleteEventFromOccurrence(eventId, beginMillis)
}
/**
* Runs an action that writes to the calendar, asking for `WRITE_CALENDAR` first
* where the install only holds read access (v1.0 installs). A grant continues
* straight into the action that asked for it.
*/
@Composable
fun rememberWriteGate(): (() -> Unit) -> Unit {
val context = LocalContext.current
var pending by remember { mutableStateOf<(() -> Unit)?>(null) }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) pending?.invoke()
pending = null
}
return { action ->
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
action()
} else {
pending = action
launcher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
}
/**
* Delete confirmation. Recurring events choose between cancelling just the
* tapped occurrence (default), truncating the series from it onwards, and
* removing the whole series.
*/
@Composable
fun DeleteEventDialog(
isRecurring: Boolean,
onConfirm: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
) {
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(
if (isRecurring) R.string.event_delete_recurring_title
else R.string.event_delete_title,
),
)
},
text = {
if (isRecurring) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { scope = RecurringWriteScope.ThisEvent },
selected = scope == RecurringWriteScope.ThisEvent,
)
OptionCard(
label = stringResource(R.string.event_delete_option_following),
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
selected = scope == RecurringWriteScope.ThisAndFollowing,
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { scope = RecurringWriteScope.AllEvents },
selected = scope == RecurringWriteScope.AllEvents,
)
}
} else {
Text(stringResource(R.string.event_delete_body))
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
) {
Text(
text = stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
private fun shareFileName(title: String): String {
val base = title.trim()
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
.replace(' ', '_')
.take(40)
.ifBlank { "event" }
return "$base.ics"
}
@@ -0,0 +1,131 @@
package de.jeanlucmakiola.calendula.ui.common
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import kotlin.coroutines.cancellation.CancellationException
import javax.inject.Inject
/** A delete waiting on its confirmation dialog. */
data class DeletePrompt(val event: EventInstance, val isRecurring: Boolean)
/** How an action on a selected event ended, when it has something to say. */
enum class EventActionFailure { LoadFailed, ShareFailed, DeleteFailed, WriteDenied, Gone }
/** Duplicate, share and delete for a selected event (#141), loading it on demand. */
@HiltViewModel
class EventActionsViewModel @Inject constructor(
private val repository: CalendarRepository,
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
/** Every calendar by id, for what a selected event's bar may offer. */
val calendars: StateFlow<Map<Long, CalendarSource>> = repository.calendars()
.map { list -> list.associateBy { it.id } }
.catch { emit(emptyMap()) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyMap(),
)
private val _deletePrompt = MutableStateFlow<DeletePrompt?>(null)
val deletePrompt: StateFlow<DeletePrompt?> = _deletePrompt.asStateFlow()
private val _failure = MutableStateFlow<EventActionFailure?>(null)
val failure: StateFlow<EventActionFailure?> = _failure.asStateFlow()
/** A create form copying [event], or null when it can't be read. */
suspend fun duplicateForm(event: EventInstance): EventForm? {
val detail = load(event) ?: return null
val canModify = calendars.value[event.calendarId]?.canModifyContents == true
return detail.toDuplicateForm(canModify, TimeZone.currentSystemDefault())
}
/** A staged `.ics` for [event], or null — reported — when it can't be built. */
suspend fun shareUri(event: EventInstance): Uri? {
val uri = load(event)?.let { withContext(io) { icsExporter.stageEventShare(it) } }
if (uri == null && _failure.value == null) _failure.value = EventActionFailure.ShareFailed
return uri
}
/** Ask to delete [event]; the dialog it needs depends on whether it recurs. */
fun requestDelete(event: EventInstance) {
viewModelScope.launch {
val detail = load(event) ?: return@launch
_deletePrompt.value = DeletePrompt(event, isRecurring = !detail.rrule.isNullOrBlank())
}
}
fun confirmDelete(scope: RecurringWriteScope) {
val prompt = _deletePrompt.value ?: return
_deletePrompt.value = null
viewModelScope.launch {
val event = prompt.event
_failure.value = try {
repository.deleteEvent(event.eventId, event.start.toEpochMilliseconds(), scope)
null
} catch (e: CancellationException) {
throw e
} catch (e: SecurityException) {
EventActionFailure.WriteDenied
} catch (e: NoSuchEventException) {
EventActionFailure.Gone
} catch (e: Exception) {
EventActionFailure.DeleteFailed
}
}
}
fun cancelDelete() {
_deletePrompt.value = null
}
/** The chooser itself couldn't open — nothing on the device takes an `.ics`. */
fun reportShareFailed() {
_failure.value = EventActionFailure.ShareFailed
}
fun consumeFailure() {
_failure.value = null
}
private suspend fun load(event: EventInstance): EventDetail? = try {
val detail = repository.eventDetail(event.eventId)
// The row holds the series start; the actions work on this occurrence.
detail.copy(instance = detail.instance.copy(start = event.start, end = event.end))
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
_failure.value = EventActionFailure.Gone
null
} catch (e: Exception) {
_failure.value = EventActionFailure.LoadFailed
null
}
}
@@ -24,31 +24,49 @@ import androidx.compose.ui.unit.dp
/** How far the finger may wander during the hold — well under touch slop. */
private val PICKUP_TOLERANCE = 6.dp
/** What a long press on a block turned into. */
enum class PickUp {
/** Nothing to pick up; the gesture is left alone. */
None,
/** Lifted and following the finger. */
Drag,
/** Held but not movable — the gesture is swallowed until the finger lifts. */
Hold,
}
/**
* Pick an event block up with a long press and drag it, without
* `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an
* ancestor consumes or the finger leaves the block, which a
* `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout
* is deliberately not consumed, so a scroll starting on top of a block survives.
*
* [onRelease] is the long press that never moved: the finger lifted within touch
* slop of where it was picked up, or a [PickUp.Hold] ended. Without it, such a
* release is a drop like any other. [immediate] also lifts the block as soon as
* the finger moves past touch slop, with no hold — for a block already selected.
*/
@Composable
fun rememberEventDragSource(
enabled: Boolean,
key: Any?,
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> Unit,
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> PickUp,
onMove: (pointerInRoot: Offset) -> Unit,
onDrop: () -> Unit,
onCancel: () -> Unit,
): Modifier = rememberDragSurface(
onRelease: (() -> Unit)? = null,
immediate: Boolean = false,
): Modifier = rememberPickUpSurface(
enabled = enabled,
key = key,
onPickUp = { _, pointerInRoot, nodeInRoot, size ->
onPickUp(pointerInRoot, nodeInRoot, size)
true
},
immediate = immediate,
onPickUp = { _, pointerInRoot, nodeInRoot, size -> onPickUp(pointerInRoot, nodeInRoot, size) },
onMove = onMove,
onDrop = onDrop,
onCancel = onCancel,
onRelease = onRelease,
)
/**
@@ -65,6 +83,29 @@ fun rememberDragSurface(
onMove: (pointerInRoot: Offset) -> Unit,
onDrop: () -> Unit,
onCancel: () -> Unit,
): Modifier = rememberPickUpSurface(
enabled = enabled,
key = key,
immediate = false,
onPickUp = { local, pointer, node, size ->
if (onPickUp(local, pointer, node, size)) PickUp.Drag else PickUp.None
},
onMove = onMove,
onDrop = onDrop,
onCancel = onCancel,
onRelease = null,
)
@Composable
private fun rememberPickUpSurface(
enabled: Boolean,
key: Any?,
immediate: Boolean,
onPickUp: (local: Offset, pointerInRoot: Offset, nodeInRoot: Offset, size: IntSize) -> PickUp,
onMove: (pointerInRoot: Offset) -> Unit,
onDrop: () -> Unit,
onCancel: () -> Unit,
onRelease: (() -> Unit)?,
): Modifier {
if (!enabled) return Modifier
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
@@ -74,27 +115,42 @@ fun rememberDragSurface(
val currentMove by rememberUpdatedState(onMove)
val currentDrop by rememberUpdatedState(onDrop)
val currentCancel by rememberUpdatedState(onCancel)
val currentRelease by rememberUpdatedState(onRelease)
// Not a key: the pickup changes it, and a restart would cancel the drag.
val currentImmediate by rememberUpdatedState(immediate)
return Modifier
.onGloballyPositioned { coordinates[0] = it }
.pointerInput(key) {
val slop = viewConfiguration.touchSlop
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val origin = down.position
val heldStill = withTimeoutOrNull(holdMillis) {
while (true) {
val immediate = currentImmediate
// Null: held still. True: an immediate pickup past slop. False: abandoned.
val outcome = withTimeoutOrNull(holdMillis) {
var result: Boolean? = null
while (result == null) {
val main = awaitPointerEvent()
val change = main.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) break
if (main.changes.count { it.pressed } > 1) break
if ((change.position - origin).getDistance() > tolerance) break
// An ancestor's claim only becomes visible once the whole
// main pass has run, so look again on the final pass.
val final = awaitPointerEvent(PointerEventPass.Final)
if (final.changes.any { it.isConsumed }) break
val change = main.changes.firstOrNull { it.id == down.id }
val distance = change?.let { (it.position - origin).getDistance() } ?: 0f
result = when {
change == null || !change.pressed -> false
main.changes.count { it.pressed } > 1 -> false
immediate && distance > slop && !change.isConsumed -> {
change.consume()
true
}
!immediate && distance > tolerance -> false
// An ancestor's claim only becomes visible once the
// whole main pass has run, so look again on the final pass.
awaitPointerEvent(PointerEventPass.Final).changes.any { it.isConsumed } -> false
else -> null
}
}
} == null
if (!heldStill) return@awaitEachGesture
result
}
if (outcome == false) return@awaitEachGesture
val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture
val took = currentPickUp(
@@ -103,8 +159,11 @@ fun rememberDragSurface(
layout.positionInRoot(),
layout.size,
)
if (!took) return@awaitEachGesture
if (took == PickUp.None) return@awaitEachGesture
// Moved already, for an immediate pickup: it can't be a release.
var moved = outcome == true
var dropped = false
var released = false
try {
// Driven on the initial pass, which runs parent → child: an
// ancestor that outranks us (the pinch) has already consumed
@@ -116,18 +175,31 @@ fun rememberDragSurface(
if (change.isConsumed) break
if (!change.pressed) {
change.consume()
dropped = true
if (took == PickUp.Hold || (!moved && currentRelease != null)) {
released = true
} else {
dropped = true
}
break
}
if (event.changes.count { it.pressed } > 1) break
change.consume()
if (took == PickUp.Hold) continue
if ((change.position - origin).getDistance() > slop) moved = true
coordinates[0]?.takeIf { it.isAttached }
?.let { currentMove(it.localToRoot(change.position)) }
}
} finally {
// Also reached when the pointer node is disposed mid-drag —
// a cancel, not a drop, and must never write.
if (dropped) currentDrop() else currentCancel()
when {
released -> {
if (took == PickUp.Drag) currentCancel()
currentRelease?.invoke()
}
dropped -> currentDrop()
took == PickUp.Drag -> currentCancel()
}
}
}
}
@@ -48,7 +48,12 @@ private val FAB_BAND = 88.dp
* a pill on the FAB's band, leaving the calendar visible behind it.
*/
@Composable
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) {
fun EventMoveHost(
viewModel: RescheduleViewModel,
modifier: Modifier = Modifier,
actionFailure: String? = null,
onActionFailureShown: () -> Unit = {},
) {
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle()
@@ -58,7 +63,9 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
prompt?.let { pending ->
RecurringScopeDialog(
title = stringResource(R.string.event_move_recurring_title),
title = stringResource(
if (pending.resize) R.string.event_resize_recurring_title else R.string.event_move_recurring_title,
),
onSelect = viewModel::moveWithScope,
onDismiss = viewModel::cancelScope,
allowSeries = !pending.occurrenceOnly,
@@ -69,9 +76,17 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
val moved = outcome as? MoveOutcome.Moved
val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) }
val resizedLabel = moved?.endMillis?.let { end ->
"$movedLabel–${formatTime(end, use24Hour, locale)}"
}
// A failed selection action (#141) shares the chip rather than stacking on it.
val message = when (outcome) {
null -> null
is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty())
null -> actionFailure
is MoveOutcome.Moved -> if (resizedLabel != null) {
stringResource(R.string.event_resize_done, resizedLabel)
} else {
stringResource(R.string.event_move_done, movedLabel.orEmpty())
}
MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
@@ -96,6 +111,12 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS)
viewModel.consumeOutcome()
}
// A failure waits for the chip to be free, then has its own turn.
LaunchedEffect(actionFailure, outcome == null) {
if (actionFailure == null || outcome != null) return@LaunchedEffect
delay(CHIP_MILLIS)
onActionFailureShown()
}
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val chipMaxWidth = maxWidth - FAB_BAND
@@ -113,7 +134,7 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
// write anyway, so offering it would be a button that does nothing.
val undo = content.undo?.takeIf { !writeInFlight }
SnackChip(
visible = outcome != null,
visible = outcome != null || actionFailure != null,
message = content.message,
maxWidth = chipMaxWidth,
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
@@ -142,3 +163,7 @@ private fun formatMovedTo(
return localizedDateFormatter(locale, skeleton)
.format(Instant.ofEpochMilli(startMillis).atZone(zone))
}
/** "10:30" — the end of a resized event, read in the device zone like its start. */
private fun formatTime(millis: Long, use24Hour: Boolean, locale: Locale): String =
timeOfDayFormatter(use24Hour, locale).format(Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()))
@@ -131,6 +131,13 @@ fun eventDragAllowed(event: EventInstance): Boolean {
return move.dragEnabled && move.allows(event)
}
/**
* Whether a long press may select events at all (#141) — the gesture setting,
* whatever the event's calendar allows. A read-only event is selected but stays put.
*/
@Composable
fun eventSelectAllowed(): Boolean = LocalEventMove.current?.dragEnabled == true
/**
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't
* pointer-only. Null when this event can't be moved.
@@ -0,0 +1,60 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/** The selected event's action bar (#141); Edit and Delete only where its calendar allows. */
@Composable
fun EventSelectionBar(
canEdit: Boolean,
canDelete: Boolean,
onEdit: () -> Unit,
onDuplicate: () -> Unit,
onShare: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
// The FAB's height and colours, so the two read as one control.
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
shadowElevation = 6.dp,
modifier = modifier.height(FabSize),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 4.dp),
) {
if (canEdit) BarAction(Icons.Default.Edit, R.string.event_detail_edit, onEdit)
BarAction(Icons.Default.ContentCopy, R.string.event_detail_duplicate, onDuplicate)
BarAction(Icons.Default.Share, R.string.event_detail_share, onShare)
if (canDelete) BarAction(Icons.Default.Delete, R.string.event_detail_delete, onDelete)
}
}
}
@Composable
private fun BarAction(icon: ImageVector, label: Int, onClick: () -> Unit) {
IconButton(onClick = onClick) {
Icon(imageVector = icon, contentDescription = stringResource(label))
}
}
@@ -0,0 +1,118 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.floret.components.SnackChipMargin
import kotlinx.coroutines.launch
/** The bottom band the selection bar and FAB cover, margin included. */
val SelectionBarClearance = FabSize + SnackChipMargin
/** The FAB's role while an event is selected (#141): its action bar's toggle. Null otherwise. */
@Composable
fun rememberFabSelection(
host: ChromeHost,
viewModel: EventActionsViewModel,
onEdit: (EventInstance) -> Unit,
onDuplicate: (EventForm) -> Unit,
chipShown: Boolean,
onDismissChip: () -> Unit,
): FabSelection? {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
val requireWrite = rememberWriteGate()
val chooserTitle = stringResource(R.string.event_share_chooser_title)
// What the FAB has been told for this selection, over what the coverage says.
var chosen by remember(host.selection?.event) { mutableStateOf<Boolean?>(null) }
// Folded while a chip is up, which then sits beside the FAB as usual.
val expanded = !chipShown && (chosen ?: !host.selectionCompact)
val currentEdit by rememberUpdatedState(onEdit)
val currentDuplicate by rememberUpdatedState(onDuplicate)
val currentDismissChip by rememberUpdatedState(onDismissChip)
val currentWrite by rememberUpdatedState(requireWrite)
val selection = host.selection ?: return null
val event = selection.event
val calendar = calendars[event.calendarId]
val canEdit = calendar?.canModifyContents == true
val canDelete = canEdit && calendar?.isManaged == false
return remember(selection, expanded, chipShown, canEdit, canDelete) {
FabSelection(
expanded = expanded,
onToggle = {
if (chipShown) currentDismissChip()
chosen = !expanded
},
bar = {
EventSelectionBar(
canEdit = canEdit,
canDelete = canDelete,
onEdit = {
currentWrite {
selection.dismiss()
currentEdit(event)
}
},
onDuplicate = {
currentWrite {
scope.launch {
val form = viewModel.duplicateForm(event) ?: return@launch
selection.dismiss()
currentDuplicate(form)
}
}
},
onShare = {
scope.launch {
val uri = viewModel.shareUri(event) ?: return@launch
if (!context.sendEventShare(uri, chooserTitle)) viewModel.reportShareFailed()
}
},
onDelete = { currentWrite { viewModel.requestDelete(event) } },
)
},
)
}
}
/** What a failed action on a selected event says, for the move host's chip to show (#141). */
@Composable
fun eventActionFailureMessage(failure: EventActionFailure?): String? = when (failure) {
null -> null
EventActionFailure.LoadFailed -> stringResource(R.string.event_action_load_failed)
EventActionFailure.ShareFailed -> stringResource(R.string.event_share_failed)
EventActionFailure.DeleteFailed -> stringResource(R.string.event_delete_failed)
EventActionFailure.WriteDenied -> stringResource(R.string.event_delete_write_denied)
EventActionFailure.Gone -> stringResource(R.string.event_move_gone)
}
/** The delete confirmation a selected event's Delete brings up (#141). */
@Composable
fun EventSelectionHost(host: ChromeHost, viewModel: EventActionsViewModel) {
val deletePrompt by viewModel.deletePrompt.collectAsStateWithLifecycle()
deletePrompt?.let { prompt ->
DeleteEventDialog(
isRecurring = prompt.isRecurring,
onConfirm = { writeScope ->
host.selection?.takeIf { it.event == prompt.event }?.dismiss()
viewModel.confirmDelete(writeScope)
},
onDismiss = viewModel::cancelDelete,
)
}
}
@@ -19,12 +19,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.floret.locale.currentLocale
/** Width of the hour gutter down the start edge of the day and week timelines. */
@@ -67,7 +70,7 @@ fun HourGutter(
val locale = currentLocale()
// Derived: the drag is rewritten every frame, its snapped start once a slot.
val dragStartMin by remember(dragController) {
derivedStateOf { dragController.drag?.startMin }
derivedStateOf { dragController.drag?.markerMin }
}
val hourAlpha by animateFloatAsState(
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
@@ -107,12 +110,25 @@ fun HourGutter(
}
}
}
dragStartMin?.let { startMin ->
// Held while it fades, so it goes out where it was rather than blinking off.
val badgeMin = remember { mutableStateOf<Int?>(null) }
dragStartMin?.let { badgeMin.value = it }
val badgeIn by animateFloatAsState(
targetValue = if (dragStartMin != null) 1f else 0f,
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
label = "gutterBadge",
)
badgeMin.value?.takeIf { badgeIn > 0f }?.let { startMin ->
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = top)
.graphicsLayer {
alpha = badgeIn
scaleX = 0.8f + 0.2f * badgeIn
scaleY = 0.8f + 0.2f * badgeIn
}
.height(BADGE_HEIGHT)
.background(MaterialTheme.colorScheme.primary, CircleShape)
.padding(horizontal = 4.dp)
@@ -121,7 +137,8 @@ fun HourGutter(
contentAlignment = Alignment.Center,
) {
Text(
text = formatGutterTime(startMin, use24Hour, locale),
// A resized end can sit on midnight: 00:00, not a clamped 23:59.
text = formatGutterTime(startMin % MINUTES_PER_DAY, use24Hour, locale),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
maxLines = 1,
@@ -5,6 +5,11 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
/**
@@ -16,6 +21,17 @@ import kotlin.time.Instant
*/
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
/**
* Whether [this] counts as past where it's drawn on [day]: once it has ended,
* or on any day before the one [now] falls in, so a multi-day event still
* running doesn't stay undimmed on the days already behind it.
*/
fun EventInstance.isPastOn(
day: LocalDate,
now: Instant,
zone: TimeZone = TimeZone.currentSystemDefault(),
): Boolean = hasEnded(now) || day < now.toLocalDateTime(zone).date
/** Opacity applied to a completed/past event chip when it is dimmed. */
const val EventDimAlpha = 0.4f
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.calendula.domain.allowsEventMove
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.realignRecurrence
import de.jeanlucmakiola.calendula.domain.resizedTo
import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.shiftedByDays
import de.jeanlucmakiola.calendula.domain.shiftedTo
@@ -54,6 +55,9 @@ sealed interface MoveTarget {
* disagree by a day over which zone the event's first day is resolved in.
*/
data class ByDays(val days: Int) : MoveTarget
/** New start and end instants — a timeline resize, which changes the length. */
data class Span(val start: Instant, val end: Instant) : MoveTarget
}
/**
@@ -74,7 +78,7 @@ data class MoveRequest(
* re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor
* across the same midnights the occurrence crossed.
*/
data class MoveScopePrompt(val occurrenceOnly: Boolean)
data class MoveScopePrompt(val occurrenceOnly: Boolean, val resize: Boolean = false)
/** The inverse of a completed move, for the undo action. */
data class MoveUndo(
@@ -88,12 +92,14 @@ data class MoveUndo(
sealed interface MoveOutcome {
/**
* Written. [startMillis] is where the dragged occurrence now begins, for the
* confirmation message; [undo] is null when the write has no clean inverse.
* confirmation message, and [endMillis] where it ends — set only for a resize,
* whose message names both; [undo] is null when the write has no clean inverse.
*/
data class Moved(
val startMillis: Long,
val isAllDay: Boolean,
val undo: MoveUndo?,
val endMillis: Long? = null,
) : MoveOutcome
data object Undone : MoveOutcome
@@ -226,7 +232,10 @@ class RescheduleViewModel @Inject constructor(
} else {
// Still busy: the scope dialog is now the thing in flight.
pending = prepared
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
_scopePrompt.value = MoveScopePrompt(
occurrenceOnly = !prepared.canRealign,
resize = request.target is MoveTarget.Span,
)
}
}
return true
@@ -313,6 +322,7 @@ class RescheduleViewModel @Inject constructor(
val shifted = when (val target = request.target) {
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
is MoveTarget.Span -> original.resizedTo(target.start, target.end, zone)
}
// A zero-distance drop is not a write.
if (shifted == original) return null
@@ -396,6 +406,7 @@ class RescheduleViewModel @Inject constructor(
startMillis = movedStartMillis(prepared),
isAllDay = prepared.updated.isAllDay,
undo = undoFor(prepared, scope),
endMillis = resizedEndMillis(prepared),
)
} catch (e: CancellationException) {
throw e
@@ -480,6 +491,14 @@ class RescheduleViewModel @Inject constructor(
null
}
/** Where a resized occurrence now ends; null for a move, which keeps its length. */
private fun resizedEndMillis(prepared: PreparedMove): Long? {
if (prepared.request.target !is MoveTarget.Span) return null
val form = prepared.updated
return form.end.toInstant(form.resolvedZone(TimeZone.currentSystemDefault()))
.toEpochMilliseconds()
}
/**
* Where the dragged occurrence now begins, in the same anchoring the views
* read dates back in: UTC midnight for an all-day event.
@@ -0,0 +1,102 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp
/** The handle's touch target, centred on the edge it moves. */
val ResizeHandleTarget = 48.dp
private val HANDLE_DOT = 16.dp
private val HANDLE_RING = 2.dp
/**
* A grip on one edge of a selected event (#141), dragged without a hold. No
* semantics: TalkBack resizes through the event's edit action.
*/
@Composable
fun ResizeHandle(
color: Color,
enabled: Boolean,
onStart: (pointerInRoot: Offset) -> Unit,
onMove: (pointerInRoot: Offset) -> Unit,
onEnd: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
val coordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
val currentStart by rememberUpdatedState(onStart)
val currentMove by rememberUpdatedState(onMove)
val currentEnd by rememberUpdatedState(onEnd)
val currentCancel by rememberUpdatedState(onCancel)
// Not a key: turning it off mid-resize must not cancel that resize.
val currentEnabled by rememberUpdatedState(enabled)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.size(ResizeHandleTarget)
.clearAndSetSemantics { }
.onGloballyPositioned { coordinates[0] = it }
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
if (!currentEnabled) return@awaitEachGesture
down.consume()
var started = false
var ended = false
try {
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
// Claimed by an ancestor that outranks us — the pinch.
if (change.isConsumed) break
if (event.changes.count { it.pressed } > 1) break
change.consume()
if (!change.pressed) {
ended = started
break
}
val root = coordinates[0]?.takeIf { it.isAttached }
?.localToRoot(change.position) ?: break
if (started) {
currentMove(root)
} else {
started = true
currentStart(root)
}
}
} finally {
when {
ended -> currentEnd()
started -> currentCancel()
}
}
}
},
) {
Box(
Modifier
.size(HANDLE_DOT)
.background(color, CircleShape)
.border(HANDLE_RING, MaterialTheme.colorScheme.surface, CircleShape),
)
}
}
@@ -0,0 +1,89 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import java.time.ZoneId
import java.util.Locale
import kotlin.time.toJavaInstant
/**
* The top bar while an event is selected (#141): says which, and when, with a
* way out — the selection otherwise only shows on the grid and in the FAB.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SelectionTopBar(selection: EventSelection) {
val event = selection.event
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val whenText = remember(event.start, event.end, use24Hour, locale) {
formatSelectionWhen(event, use24Hour, locale)
}
TopAppBar(
navigationIcon = {
IconButton(onClick = selection.dismiss) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.event_selection_close),
)
}
},
title = {
Column {
Text(
text = event.title.ifBlank { stringResource(R.string.event_untitled) },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = whenText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
// The header's own surface, as the view's bar has (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
}
/**
* "Fri, 9 Oct · 09:00–10:00", or with both days named for an event that runs
* into another: "Fri, 9 Oct, 22:00 – Sat, 10 Oct, 02:00".
*/
private fun formatSelectionWhen(event: EventInstance, use24Hour: Boolean, locale: Locale): String {
val zone = ZoneId.systemDefault()
val start = event.start.toJavaInstant().atZone(zone)
val end = event.end.toJavaInstant().atZone(zone)
val day = localizedDateFormatter(locale, "EEEdMMM")
val time = timeOfDayFormatter(use24Hour, locale)
val endsSameDay = end.toLocalDate() == start.toLocalDate() ||
(end.toLocalTime().toSecondOfDay() == 0 && end.toLocalDate() == start.toLocalDate().plusDays(1))
return if (endsSameDay) {
"${day.format(start)} · ${time.format(start)}–${time.format(end)}"
} else {
"${day.format(start)}, ${time.format(start)} – ${day.format(end)}, ${time.format(end)}"
}
}
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.MutatePriority
@@ -20,6 +21,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -27,6 +29,7 @@ import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.LayoutCoordinates
@@ -35,6 +38,7 @@ import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
@@ -97,11 +101,28 @@ data class TimelineDrag(
* drop lands, so it is dropped once the gesture ends ([finish]).
*/
val edgeOnly: Boolean = false,
/**
* The minute of its day the gutter badge names — the top edge, or for a
* bottom-edge resize the end being dragged (#141).
*/
val markerMin: Int = startMin,
) {
/** What the snap haptics key off: one tick per changed slot, not per frame. */
val slot: Pair<LocalDate, Int> get() = date to startMin
/**
* What the snap haptics key off: one tick per changed slot, not per frame.
* Carries the length, which is all a bottom-edge resize changes.
*/
val slot: Triple<LocalDate, Int, Int> get() = Triple(date, startMin, eventSpanMin)
}
/** The event a long press left selected (#141). */
data class TimelineSelection(
val event: EventInstance,
/** Set from a move or resize of it until the grid shows where it landed. */
val moved: Boolean = false,
/** Selected by letting go of a lifted block, which then keeps that lift as it is. */
val fromLift: Boolean = false,
)
/**
* One day column's share of a dragged event, ready to draw. Both halves of an
* event crossing midnight are previewed, whichever half the finger picked up:
@@ -248,6 +269,56 @@ data class TimelineDrop(
val clipOffsetMin: Int = 0,
)
/** Which end of an event a resize handle moves (#141). */
enum class ResizeEdge { Top, Bottom }
/** Where a finished resize asks its event to begin and end. */
data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant)
/**
* The span after dragging [edge] to [edgeMin] in day [edgeDay], in minutes from the
* start day's midnight. Snapped, and at least one snap step long.
*/
internal fun resizedSpan(
edge: ResizeEdge,
startMin: Int,
endMin: Int,
edgeMin: Float,
edgeDay: Int? = null,
): Pair<Int, Int> {
// An edge dragged into a day past the other end changes nothing.
if (edgeDay != null) {
val endDay = (endMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
val wrongWay = when (edge) {
ResizeEdge.Top -> edgeDay > endDay
ResizeEdge.Bottom -> edgeDay < startMin.floorDiv(MINUTES_PER_DAY)
}
if (wrongWay) return startMin to endMin
}
val snapped = (edgeMin / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
return when (edge) {
ResizeEdge.Top -> minOf(snapped, endMin - DRAG_SNAP_MINUTES) to endMin
ResizeEdge.Bottom -> startMin to maxOf(snapped, startMin + DRAG_SNAP_MINUTES)
}
}
/** [minutes] snapped, kept at [held] until three quarters of a step away. */
internal fun stickySnap(minutes: Float, held: Int?): Int {
if (held != null && kotlin.math.abs(minutes - held) < DRAG_SNAP_MINUTES * 0.75f) return held
return (minutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
}
/** How far past its column's side the finger has to go before a resize changes day. */
private const val COLUMN_HYSTERESIS = 0.25f
/** The column under [position], kept at [held] until [COLUMN_HYSTERESIS] into another. */
internal fun stickyColumn(position: Float, held: Int?, lastIndex: Int): Int {
val under = position.toInt().coerceIn(0, lastIndex)
if (held == null) return under
val stays = position >= held - COLUMN_HYSTERESIS && position < held + 1 + COLUMN_HYSTERESIS
return if (stays) held else under
}
/**
* The timeline's live geometry, republished on every layout. Plain fields rather
* than snapshot state: it changes on every scroll frame, and the drag loop reads
@@ -320,10 +391,88 @@ class TimelineDragController {
*/
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
/** The selected event, if any — see [TimelineSelection]. */
var selection: TimelineSelection? by mutableStateOf(null)
private set
fun select(event: EventInstance, moved: Boolean = false, fromLift: Boolean = false) {
if (selection?.event?.instanceId != event.instanceId) selectionCovered = false
selection = TimelineSelection(event, moved, fromLift)
}
fun deselect() {
selection = null
selectionCovered = false
}
/** The events of the blocks on screen that show the selection; none for a while ends it. */
val selectedCandidates = mutableStateListOf<EventInstance>()
fun noteSelectedBlock(event: EventInstance, shown: Boolean) {
if (shown) selectedCandidates.add(event) else selectedCandidates.remove(event)
}
/**
* Once a move of the selected event has settled, point the selection at the
* row it landed as. A new event id wins: a single moved occurrence is written
* as an exception, and another occurrence of its series may share its time.
*/
fun settleMovedSelection() {
val current = selection?.takeIf { it.moved } ?: return
if (isDragging || settling != null) return
val landed = selectedCandidates.filter { it.instanceId != current.event.instanceId }
// Only the original row left: the move was abandoned, and it still stands.
val chosen = landed.firstOrNull { it.eventId != current.event.eventId }
?: landed.firstOrNull() ?: selectedCandidates.firstOrNull() ?: return
selection = current.copy(event = chosen, moved = false)
}
/** Whether the selected block's bottom sits where the action bar would cover it. */
var selectionCovered: Boolean by mutableStateOf(false)
private set
/**
* Whether [block] is the selected event: by instance, by series and start, and
* just after a move by title and times, since a moved occurrence gets new ids.
*/
fun isSelected(block: TimedBlock): Boolean {
val current = selection ?: return false
val selected = current.event
val event = block.event
return event.instanceId == selected.instanceId ||
(event.eventId == selected.eventId && event.start == selected.start) ||
(
current.moved &&
event.calendarId == selected.calendarId &&
event.title.isNotBlank() &&
event.title == selected.title &&
event.start == selected.start &&
event.end == selected.end
)
}
/** Point the selection at [block]'s event, so the bar acts on the row now there. */
fun adoptSelection(block: TimedBlock) {
val current = selection?.takeIf { !it.moved } ?: return
if (current.event != block.event) selection = current.copy(event = block.event)
}
/** Whether a finger is on a block right now — [settling] is not dragging. */
var isDragging: Boolean by mutableStateOf(false)
private set
/** Whether the finger has moved since the pickup; only then may the view auto-scroll. */
var pointerMoved: Boolean by mutableStateOf(false)
private set
/** Touch slop, past which the finger counts as having moved. */
var slopPx: Float = 0f
private var pickupPointer = Offset.Zero
/** The end a settling resize asked for, which the grid has to show before it settles. */
private var settledEnd: Instant? = null
/**
* Whether the grid itself now draws the settled drop at its landing slot.
* The copy may only be handed back once this is true, or the source ghost
@@ -332,6 +481,10 @@ class TimelineDragController {
var settledOnGrid: Boolean by mutableStateOf(false)
private set
/** Whether the settling drop is the selected event, whose block lands still lifted. */
var settlingSelected: Boolean = false
private set
/**
* The abandoned-move counter, read at the drop so the mark can't be a
* composition behind the tick it is compared against.
@@ -361,7 +514,27 @@ class TimelineDragController {
* The slot the block already occupied when it was picked up — not the same
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
*/
private var originSlot: Pair<LocalDate, Int>? = null
private var originSlot: Triple<LocalDate, Int, Int>? = null
/** The edge a resize is dragging, or null for a plain drag (#141). */
private var resizeEdge: ResizeEdge? = null
/** The day the resized event starts on; its edges are minutes from that midnight. */
private var resizeDay: LocalDate? = null
private var resizeStartMin = 0
private var resizeEndMin = 0
/** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */
private var resizeGrabMin = 0f
/** The column the resized edge is in, held until the finger is well into another. */
private var resizeColumn: Int? = null
/** The slot the resized edge sits on, held against a finger trembling between two. */
private var resizeSlot: Int? = null
/** The day, counted from the event's start day, the finger is resizing in. */
private var resizeEdgeDay = 0
fun begin(
block: TimedBlock,
@@ -370,6 +543,8 @@ class TimelineDragController {
pointerInRoot: Offset,
blockInRoot: Offset,
) {
// The selected event keeps its selection through a drag; any other ends it.
if (!isSelected(block)) deselect()
source = block
this.clipOffsetMin = clipOffsetMin
this.titleLines = titleLines
@@ -378,6 +553,8 @@ class TimelineDragController {
liftedInstanceId = block.event.instanceId
grab = pointerInRoot - blockInRoot
pointer = pointerInRoot
pickupPointer = pointerInRoot
pointerMoved = false
originSlot = null
recompute()
originSlot = drag?.slot
@@ -385,20 +562,28 @@ class TimelineDragController {
fun move(pointerInRoot: Offset) {
pointer = pointerInRoot
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
recompute()
}
fun cancel() {
resizeEdge = null
resizeDay = null
resizeColumn = null
resizeSlot = null
source = null
clipOffsetMin = 0
titleLines = 0
isDragging = false
pointerMoved = false
liftedInstanceId = null
originSlot = null
drag = null
settling = null
settledOnGrid = false
settledOrigin = null
settledEnd = null
settlingSelected = false
}
/**
@@ -429,8 +614,11 @@ class TimelineDragController {
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
.firstOrNull() ?: return
val end = settledEnd
settledOnGrid = blocks.any { block ->
block.startMin == slice.startMin &&
// A resize may keep its start, so its end has to have landed too.
(end == null || block.event.end == end) &&
(
block.event.eventId == landed.event.eventId ||
(
@@ -452,6 +640,7 @@ class TimelineDragController {
val clipOffset = clipOffsetMin
cancel()
if (landed == null || landed.slot == origin) return null
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
// An edge piece is a hint for the finger, not a preview of the landing
// slot: the event belongs to a day this timeline has no column for, so
// [noteGrid] can never seat it and it would sit at the top of today for
@@ -463,6 +652,150 @@ class TimelineDragController {
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
}
/**
* Grab one edge of [block] to resize it (#141). The edge follows the finger
* across days as a drag does; the other stays where it is.
*/
fun beginResize(
block: TimedBlock,
edge: ResizeEdge,
zone: TimeZone,
titleLines: Int,
pointerInRoot: Offset,
) {
val start = block.event.start.toLocalDateTime(zone)
val end = block.event.end.toLocalDateTime(zone)
source = block
resizeEdge = edge
resizeDay = start.date
resizeStartMin = start.time.toSecondOfDay() / 60
// Wall clock at both ends, as the drop resolves them (DST-safe).
resizeEndMin = maxOf(
resizeStartMin,
(end.date.toEpochDays() - start.date.toEpochDays()).toInt() * MINUTES_PER_DAY +
end.time.toSecondOfDay() / 60,
)
clipOffsetMin = 0
this.titleLines = titleLines
settling = null
isDragging = true
pointerMoved = true
liftedInstanceId = block.event.instanceId
pointer = pointerInRoot
// Measured from the edge's own column: the handle overhangs into the next.
val edgeDay = if (edge == ResizeEdge.Top) 0 else (resizeEndMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
val edgeIndex = geometry.days.indexOf(start.date.plus(edgeDay, DateTimeUnit.DAY))
resizeColumn = edgeIndex.takeIf { it >= 0 }
?.let { if (geometry.isRtl) geometry.days.lastIndex - it else it }
resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin
originSlot = null
recompute()
originSlot = drag?.slot
}
/**
* End a resize, handing back the event's new span — null when it never
* resolved or came back to the length it started at. Settles as [finish] does.
*/
fun finishResize(zone: TimeZone): TimelineResizeDrop? {
val landed = drag
val origin = originSlot
cancel()
if (landed == null || landed.slot == origin) return null
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start
settledAbandonedMark = abandonedTicks?.value ?: 0
val drop = TimelineResizeDrop(
event = landed.event,
start = wallClockInstant(landed.date, landed.eventStartMin, zone),
end = wallClockInstant(landed.date, landed.eventStartMin + landed.eventSpanMin, zone),
)
settledEnd = drop.end
return drop
}
/**
* Where the finger is, in minutes from the resized event's start day —
* past 1440 in a later column, negative in an earlier one.
*/
private fun pointerResizeMinutes(): Float? {
val grid = geometry.grid?.takeIf { it.isAttached } ?: return null
val day = resizeDay ?: return null
val hourPx = geometry.hourPx
val columnPx = geometry.columnWidthPx
val days = geometry.days
if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return null
val origin = grid.positionInRoot()
val column = stickyColumn(
position = (pointer.x - origin.x) / columnPx,
held = resizeColumn,
lastIndex = days.lastIndex,
)
resizeColumn = column
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
val dayOffset = (days[dayIndex].toEpochDays() - day.toEpochDays()).toInt()
resizeEdgeDay = dayOffset
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
}
private fun recomputeResize(edge: ResizeEdge) {
val block = source ?: return
val day = resizeDay ?: return
val raw = pointerResizeMinutes() ?: return
val slot = stickySnap(raw - resizeGrabMin, resizeSlot)
resizeSlot = slot
val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat(), resizeEdgeDay)
val days = geometry.days
val hourPx = geometry.hourPx
val columnPx = geometry.columnWidthPx
val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return
// Which column the start day sits in, possibly off either end of the view.
val startIndex = (day.toEpochDays() - days.first().toEpochDays()).toInt()
val visible = -startIndex..days.lastIndex - startIndex
val slices = dragSlices(start, end - start, within = visible)
drag = TimelineDrag(
event = block.event,
date = day,
startMin = start,
eventStartMin = start,
eventSpanMin = end - start,
titleLines = titleLines,
pieces = slices.map { slice ->
val dayIndex = startIndex + slice.dayOffset
val col = if (geometry.isRtl) days.lastIndex - dayIndex else dayIndex
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
TimelineDragPiece(
topLeftInRoot = Offset(x = origin.x + col * columnPx, y = origin.y + bounds.top),
sizePx = IntSize(
(columnPx - geometry.columnGapPx).roundToInt(),
bounds.height.roundToInt(),
),
continuesBefore = slice.continuesBefore,
continuesAfter = slice.continuesAfter,
)
},
edgeOnly = slices.isEmpty(),
markerMin = if (edge == ResizeEdge.Top) {
start.mod(MINUTES_PER_DAY)
} else {
end - (end - 1).floorDiv(MINUTES_PER_DAY) * MINUTES_PER_DAY
},
)
}
/** Note whether the action bar would cover the selected block's bottom handle (#141). */
fun noteSelectedBottom(block: LayoutCoordinates, extraPx: Float, clearancePx: Float) {
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return
if (!block.isAttached) return
val port = viewport.boundsInRoot()
val bottom = block.positionInRoot().y + block.size.height + extraPx
val covered = bottom > port.bottom - clearancePx
if (covered != selectionCovered) selectionCovered = covered
}
/**
* Stop ghosting the source — the grid draws the drop itself by now — while
* the copy is still dissolving. Un-ghosting at [release] instead would leave
@@ -477,6 +810,8 @@ class TimelineDragController {
fun release() {
settling = null
settledOnGrid = false
settledEnd = null
settlingSelected = false
handOver()
}
@@ -486,6 +821,10 @@ class TimelineDragController {
* no move events while auto-scroll walks the content underneath it.
*/
fun recompute() {
resizeEdge?.let {
recomputeResize(it)
return
}
val block = source ?: return
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
val hourPx = geometry.hourPx
@@ -577,6 +916,7 @@ fun rememberTimelineDragController(): TimelineDragController {
val density = LocalDensity.current
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
controller.slopPx = LocalViewConfiguration.current.touchSlop
controller.abandonedTicks = LocalEventMove.current?.abandoned
return controller
}
@@ -613,14 +953,13 @@ fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
* of its own; `toInstant` resolves it forward by the missing hour, and the block
* visibly settles there.
*/
fun TimelineDrop.startInstant(zone: TimeZone): Instant {
// Wall clock throughout, and past this day at either end: a block's top edge
// can be dragged above its midnight and a tail's below the next one, so the
// start is resolved as a day plus a minute of it rather than as an offset
// from an instant — which would shift by an hour across a DST boundary.
val fromMidnight = startMin - clipOffsetMin
val day = date.plus(fromMidnight.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
val minute = fromMidnight.mod(MINUTES_PER_DAY)
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
wallClockInstant(date, startMin - clipOffsetMin, zone)
/** [minutes] past [date]'s midnight, either way past it, as wall clock in [zone] (DST-safe). */
fun wallClockInstant(date: LocalDate, minutes: Int, zone: TimeZone): Instant {
val day = date.plus(minutes.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
val minute = minutes.mod(MINUTES_PER_DAY)
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
}
@@ -660,8 +999,10 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
// Both live here rather than beside the controller: they read [drag], which
// changes every frame, and this composable is the one that is meant to.
DragSnapHaptics(controller.drag?.slot)
LaunchedEffect(controller.isDragging) {
if (controller.isDragging) controller.autoScroll()
// Only once the finger has moved: a long press held near an edge must not
// scroll the drop target out from under a finger that only means to select.
LaunchedEffect(controller.isDragging && controller.pointerMoved) {
if (controller.isDragging && controller.pointerMoved) controller.autoScroll()
}
// Hold the landed copy until the write is done — including the time a
// recurring drop's scope dialog is up — and then until the grid draws the
@@ -693,7 +1034,8 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
// Landed: the copy sinks back to the grid's own plane while the write runs.
val landed = controller.drag == null
val lift by animateFloatAsState(
targetValue = if (landed || reduceMotion) 0f else 1f,
// A selected event's block is lifted too: sinking first made it shrink, then grow back.
targetValue = if (reduceMotion || (landed && !controller.settlingSelected)) 0f else 1f,
label = "drag-lift",
)
val copyAlpha by animateFloatAsState(
@@ -880,3 +1222,29 @@ private fun DragCopy(
}
}
}
/** How far a selected block is lifted, 0 to 1 (#141); [snap] takes it over at once. */
@Composable
fun selectionLift(selected: Boolean, snap: Boolean = false): Float {
val reduceMotion = rememberReduceMotion()
val spec = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
val raise = remember { Animatable(if (selected) 1f else 0f) }
LaunchedEffect(selected) {
val target = if (selected) 1f else 0f
if (reduceMotion || (selected && snap)) raise.snapTo(target) else raise.animateTo(target, spec)
}
return raise.value
}
/**
* A selected block raised off the grid, the way a dragged copy is: scaled from
* its top edge so the text doesn't creep, with a shadow in the block's shape.
*/
fun Modifier.selectionLift(raise: Float, shape: Shape): Modifier = graphicsLayer {
transformOrigin = TransformOrigin(0.5f, 0f)
scaleX = 1f + 0.02f * raise
scaleY = 1f + 0.02f * raise
shadowElevation = 6.dp.toPx() * raise
this.shape = shape
clip = false
}
@@ -1,13 +1,9 @@
package de.jeanlucmakiola.calendula.ui.detail
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@@ -41,7 +37,6 @@ import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -51,7 +46,6 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
@@ -67,7 +61,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
@@ -90,13 +83,15 @@ import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
@@ -104,7 +99,6 @@ import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
@@ -162,43 +156,15 @@ fun EventDetailScreen(
val onShareClick = {
scope.launch {
val uri = viewModel.shareUri()
val sent = uri != null && runCatching {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/calendar"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(send, shareChooserTitle))
}.isSuccess
val sent = uri != null && context.sendEventShare(uri, shareChooserTitle)
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
}
Unit
}
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
// upgrade in place. Granting continues straight into the tapped action —
// edit, delete, or duplicate — held here until the result comes back.
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
val writePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) pendingWrite?.invoke()
pendingWrite = null
}
val hasWritePermission = {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
}
val requireWrite: (() -> Unit) -> Unit = { action ->
if (hasWritePermission()) {
action()
} else {
pendingWrite = action
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
// upgrade in place and continues straight into the tapped action.
val requireWrite = rememberWriteGate()
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
val onEditClick = { requireWrite(onEdit) }
// Duplicate reads the loaded detail into a create form and hands it up; the
@@ -322,70 +288,6 @@ fun EventDetailScreen(
}
}
/**
* Delete confirmation. Recurring events choose between cancelling just the
* tapped occurrence (default), truncating the series from it onwards, and
* removing the whole series.
*/
@Composable
private fun DeleteEventDialog(
isRecurring: Boolean,
onConfirm: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
) {
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(
if (isRecurring) R.string.event_delete_recurring_title
else R.string.event_delete_title,
),
)
},
text = {
if (isRecurring) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { scope = RecurringWriteScope.ThisEvent },
selected = scope == RecurringWriteScope.ThisEvent,
)
OptionCard(
label = stringResource(R.string.event_delete_option_following),
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
selected = scope == RecurringWriteScope.ThisAndFollowing,
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { scope = RecurringWriteScope.AllEvents },
selected = scope == RecurringWriteScope.AllEvents,
)
}
} else {
Text(stringResource(R.string.event_delete_body))
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
) {
Text(
text = stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
@Composable
private fun EventDetailContent(
state: EventDetailUiState.Success,
@@ -11,12 +11,11 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
import de.jeanlucmakiola.calendula.domain.toEditForm
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
import de.jeanlucmakiola.calendula.ui.common.deleteEvent
import de.jeanlucmakiola.calendula.ui.common.stageEventShare
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import kotlin.time.Clock
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -113,14 +112,7 @@ class EventDetailViewModel @Inject constructor(
viewModelScope.launch {
_deleteState.value = DeleteUiState.Deleting
_deleteState.value = try {
when (scope) {
RecurringWriteScope.AllEvents ->
repository.deleteEvent(target.eventId)
RecurringWriteScope.ThisEvent ->
repository.deleteOccurrence(target.eventId, target.beginMillis)
RecurringWriteScope.ThisAndFollowing ->
repository.deleteEventFromOccurrence(target.eventId, target.beginMillis)
}
repository.deleteEvent(target.eventId, target.beginMillis, scope)
DeleteUiState.Deleted
} catch (e: CancellationException) {
throw e
@@ -144,40 +136,16 @@ class EventDetailViewModel @Inject constructor(
*/
suspend fun shareUri(): Uri? {
val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null
return runCatching {
withContext(io) {
val ics = IcsWriter().writeCalendar(
events = listOf(detail.toShareIcsEvent()),
dtStamp = Clock.System.now(),
)
icsExporter.stageShareFile(shareFileName(detail.instance.title), ics)
}
}.getOrNull()
return withContext(io) { icsExporter.stageEventShare(detail) }
}
/**
* Build a create form seeded from the open event so the user can save a
* copy as a new, independent event (#52). Returns null when nothing is
* loaded.
*
* The recurrence is dropped — a duplicate is a single event; anyone who
* wants a series can re-add one in the edit form that opens. The source
* calendar is kept only when it's writable, otherwise the id is cleared so
* the create resolves to the last-used/first-writable calendar — which lets
* a read-only event (a WebCal subscription, a birthday) be copied into an
* editable calendar. The occurrence's own times carry over unchanged.
* A create form seeded from the open event, to save a copy as a new event
* (#52) — see [toDuplicateForm]. Null when nothing is loaded.
*/
fun duplicateForm(): EventForm? {
val loaded = state.value as? EventDetailUiState.Success ?: return null
val detail = loaded.detail
return detail.toEditForm(
beginMillis = detail.instance.start.toEpochMilliseconds(),
endMillis = detail.instance.end.toEpochMilliseconds(),
zone = TimeZone.currentSystemDefault(),
).copy(
rrule = null,
calendarId = detail.instance.calendarId.takeIf { loaded.canModify },
)
return loaded.detail.toDuplicateForm(loaded.canModify, TimeZone.currentSystemDefault())
}
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
@@ -228,13 +196,3 @@ class EventDetailViewModel @Inject constructor(
const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE
}
}
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
private fun shareFileName(title: String): String {
val base = title.trim()
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
.replace(' ', '_')
.take(40)
.ifBlank { "event" }
return "$base.ics"
}
@@ -87,6 +87,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -170,6 +171,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.isPastOn
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
@@ -197,13 +199,17 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.YearMonth
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
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 }
/** A brief push from today's full cell into its immediate neighbours. */
@@ -319,12 +325,7 @@ fun MonthScreen(
}
val dense = viewStyle == MonthViewStyle.Dense
// Today, from whichever state is driving; the clock only covers the first
// 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
val today by viewModel.today.collectAsStateWithLifecycle()
// 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
@@ -520,6 +521,7 @@ fun MonthScreen(
LocalDimCutoff provides dimCutoff,
LocalMonthDrag provides chipDrag,
LocalTodayPulse provides todayPulse,
LocalMonthToday provides today,
LocalOpenWeek provides onOpenWeek,
) {
if (scrolling) {
@@ -773,6 +775,7 @@ private fun MonthPage(
) {
val ym = yearMonthForIndex(index)
val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle()
val today = LocalMonthToday.current
val chipDrag = LocalMonthDrag.current
val morph = LocalMonthMorph.current
val viewMorph = LocalViewMorph.current
@@ -782,7 +785,7 @@ private fun MonthPage(
LocalViewMorph provides viewMorph.takeIf { morphActive },
) {
when (val s = state) {
is MonthUiState.Success -> page(index, s)
is MonthUiState.Success -> page(index, s.withToday(today))
else -> loading()
}
}
@@ -1388,7 +1391,8 @@ private fun SplitMonthContent(
val ym = yearMonthForIndex(index)
val pageState by remember(ym, weekStart) { monthState(ym, weekStart) }
.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
// neighbours repeat some of its dates and would claim them.
val active = index == pagerState.settledPage
@@ -1937,7 +1941,7 @@ private fun SplitDots(
modifier = Modifier
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
.size(SPLIT_DOT_SIZE)
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
.alpha(if (dimCutoff != null && event.isPastOn(date, dimCutoff)) EventDimAlpha else 1f)
.background(eventAccent(event.color, dark, soften), CircleShape),
)
}
@@ -1951,7 +1955,7 @@ private fun SplitDots(
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.morphBounds(MonthMorphKey.Overflow(date))
.alpha(if (allEnded(hidden, dimCutoff)) EventDimAlpha else 1f),
.alpha(if (allEnded(hidden, date, dimCutoff)) EventDimAlpha else 1f),
)
}
}
@@ -1994,7 +1998,7 @@ private fun SplitMonthAgendaPane(
day = day,
zone = state.zone,
position = positionOf(index, events.size),
dimmed = dimCutoff != null && event.hasEnded(dimCutoff),
dimmed = dimCutoff != null && event.isPastOn(day, dimCutoff),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day)),
onClick = { onEventClick(event) },
)
@@ -2063,7 +2067,7 @@ internal fun SplitDayPane(
day = day,
zone = zone,
position = positionOf(index, dayEvents.size),
dimmed = dimCutoff != null && event.hasEnded(dimCutoff),
dimmed = dimCutoff != null && event.isPastOn(day, dimCutoff),
onClick = { onEventClick(event) },
// An event added to or removed from the day you are
// already looking at moves its neighbours rather
@@ -2539,6 +2543,7 @@ private fun MonthWeekRow(
}
OverflowDots(
events = hiddenEvents,
day = d,
total = hidden,
dark = dark,
rowHeight = overflowRow,
@@ -2863,7 +2868,9 @@ private fun MonthBar(
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
// A bar is one piece, so it dims once the last day it covers has passed.
val dimmed = dimCutoff != null &&
(event.hasEnded(dimCutoff) || days?.let { event.isPastOn(it.last(), dimCutoff) } == true)
val paint = eventPaint(event, dark)
// The same title/secondary ink pairing the week and day blocks use, with
// the time on the quieter half.
@@ -2916,6 +2923,7 @@ private fun MonthBar(
@Composable
private fun OverflowDots(
events: List<EventInstance>,
day: LocalDate,
total: Int,
dark: Boolean,
rowHeight: Dp,
@@ -2934,7 +2942,7 @@ private fun OverflowDots(
Box(
modifier = Modifier
.size(OVERFLOW_DOT_SIZE)
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
.alpha(if (allEnded(byColor.getValue(argb), day, dimCutoff)) EventDimAlpha else 1f)
.background(eventAccent(argb, dark, soften), CircleShape),
)
}
@@ -2944,15 +2952,15 @@ private fun OverflowDots(
text = "+$extra",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.alpha(if (allEnded(events, dimCutoff)) EventDimAlpha else 1f),
modifier = Modifier.alpha(if (allEnded(events, day, dimCutoff)) EventDimAlpha else 1f),
)
}
}
}
/** True when dimming is on and every one of [events] is already over. */
private fun allEnded(events: List<EventInstance>, dimCutoff: Instant?): Boolean =
dimCutoff != null && events.isNotEmpty() && events.all { it.hasEnded(dimCutoff) }
/** True when dimming is on and every one of [events] is already over on [day]. */
private fun allEnded(events: List<EventInstance>, day: LocalDate, dimCutoff: Instant?): Boolean =
dimCutoff != null && events.isNotEmpty() && events.all { it.isPastOn(day, dimCutoff) }
@Composable
private fun MonthGridLoading() {
@@ -7,6 +7,8 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
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.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
@@ -45,7 +47,6 @@ import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
@@ -57,6 +58,7 @@ private const val MONTH_CACHE_SPAN = 2
class MonthViewModel @Inject constructor(
private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
@@ -90,7 +92,18 @@ class MonthViewModel @Inject constructor(
)
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))
val month: StateFlow<YearMonth> = _month
@@ -139,8 +152,9 @@ class MonthViewModel @Inject constructor(
combine(
repository.calendars(),
repository.instances(monthGridRange(ym, ws, zone)),
) { calendars, instances ->
buildState(ym, ws, calendars, instances)
today,
) { calendars, instances, today ->
buildState(ym, ws, calendars, instances, today)
}
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
@@ -211,8 +225,9 @@ class MonthViewModel @Inject constructor(
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildContinuousState(window, ws, style, calendars, instances)
today,
) { calendars, instances, today ->
buildContinuousState(window, ws, style, calendars, instances, today)
}
}
// A load landing is the cue to reach further out. Widening from here
@@ -257,6 +272,7 @@ class MonthViewModel @Inject constructor(
style: MonthViewStyle,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
today: LocalDate,
): ContinuousMonthUiState {
calendarListFailure(calendars)?.let { return ContinuousMonthUiState.Failure(it) }
// Bucketed once for the whole window: a row then takes the handful of
@@ -283,7 +299,7 @@ class MonthViewModel @Inject constructor(
emptyMap()
}
return ContinuousMonthUiState.Success(
today = todayDate,
today = today,
monthsByIndex = months,
weeksByIndex = weeks,
weekStart = weekStart,
@@ -333,6 +349,18 @@ class MonthViewModel @Inject constructor(
_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
* navigate by scroll position and never set [_month] themselves, so it would
@@ -358,12 +386,13 @@ class MonthViewModel @Inject constructor(
weekStart: DayOfWeek,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
today: LocalDate,
): MonthUiState {
calendarListFailure(calendars)?.let { return MonthUiState.Failure(it) }
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
return MonthUiState.Success(
month = ym,
today = todayDate,
today = today,
weeks = weeks,
instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone),
zone = zone,
@@ -59,11 +59,28 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.activity.compose.BackHandler
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.zIndex
import androidx.compose.ui.geometry.Offset
import de.jeanlucmakiola.calendula.ui.common.PickUp
import de.jeanlucmakiola.calendula.ui.common.SelectionBarClearance
import de.jeanlucmakiola.calendula.ui.common.ResizeEdge
import de.jeanlucmakiola.calendula.ui.common.ResizeHandle
import de.jeanlucmakiola.calendula.ui.common.ResizeHandleTarget
import de.jeanlucmakiola.calendula.ui.common.TimelineResizeDrop
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.PublishSelection
import de.jeanlucmakiola.calendula.ui.common.eventSelectAllowed
import de.jeanlucmakiola.calendula.ui.common.selectionLift
import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
import de.jeanlucmakiola.calendula.ui.common.StayPut
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.flow.first
import androidx.compose.runtime.withFrameNanos
@@ -79,6 +96,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.ui.layout.layout
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.pointer.pointerInput
@@ -98,7 +116,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
import de.jeanlucmakiola.calendula.ui.common.eventPaint
import de.jeanlucmakiola.calendula.ui.common.eventSurface
@@ -135,6 +152,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.isPastOn
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
import de.jeanlucmakiola.calendula.ui.common.NowLine
@@ -461,6 +479,7 @@ private fun PagedTimelineScreen(
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
WeekContent(
state = state,
today = today,
pagerState = pagerState,
grid = grid,
kind = kind,
@@ -482,6 +501,7 @@ private fun PagedTimelineScreen(
@Composable
private fun WeekContent(
state: WeekUiState,
today: LocalDate,
pagerState: PagerState,
grid: PageGrid,
kind: TimelineKind,
@@ -509,6 +529,14 @@ private fun WeekContent(
val move = LocalEventMove.current
val zone = remember { TimeZone.currentSystemDefault() }
val onDrop: (TimelineDrop) -> Unit = { drop ->
// A selected event stays selected where it landed (#141).
if (dragController.selection?.event?.instanceId == drop.event.instanceId) {
val start = drop.startInstant(zone)
dragController.select(
drop.event.copy(start = start, end = start + (drop.event.end - drop.event.start)),
moved = true,
)
}
move?.move(
MoveRequest(
eventId = drop.event.eventId,
@@ -519,6 +547,51 @@ private fun WeekContent(
)
}
// A resize keeps its event selected, at its new length (#141).
val onResize: (TimelineResizeDrop) -> Unit = { drop ->
dragController.select(
drop.event.copy(start = drop.start, end = drop.end),
moved = true,
)
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Span(drop.start, drop.end),
),
)
}
// Selection (#141) ends on back, a page or view change, or any tap.
val selection = dragController.selection
PublishSelection(
event = selection?.event,
busy = dragController.isDragging,
compact = dragController.selectionCovered,
onDismiss = dragController::deselect,
)
BackHandler(enabled = selection != null) { dragController.deselect() }
LaunchedEffect(pagerState, dragController) {
snapshotFlow { pagerState.currentPage }.drop(1).collect { dragController.deselect() }
}
LaunchedEffect(morph != null) { if (morph != null) dragController.deselect() }
LaunchedEffect(selection, dragController.settling, dragController.isDragging, dragController.selectedCandidates.size) {
dragController.settleMovedSelection()
}
val orphaned = selection != null && dragController.selectedCandidates.isEmpty() &&
!dragController.isDragging && dragController.settling == null
LaunchedEffect(orphaned) {
if (!orphaned) return@LaunchedEffect
// A beat for a re-read to bring the event back under a new row.
delay(SELECTION_ORPHAN_GRACE_MILLIS)
dragController.deselect()
}
val openEvent: (EventInstance) -> Unit = { event ->
dragController.deselect()
onEventClick(event)
}
Box(modifier = modifier) {
CompositionLocalProvider(LocalOpeningTimeline provides timelineScroll) {
// Loading and failure concern the provider as a whole, so they stand in
@@ -536,27 +609,29 @@ private fun WeekContent(
is WeekUiState.Success -> if (morph != null) {
TimelineMorphFrame(
morph = morph,
today = s.today,
today = today,
scrollState = scrollState,
dragController = dragController,
onEventClick = onEventClick,
onEventClick = openEvent,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
} else WeekPager(
pagerState = pagerState,
grid = grid,
kind = kind,
span = span,
today = s.today,
today = today,
initialStripHeight = handoffStrip ?: s.allDayStripHeight(),
scrollState = scrollState,
dragController = dragController,
onEventClick = onEventClick,
onEventClick = openEvent,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -583,6 +658,7 @@ private fun WeekPager(
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
@@ -618,7 +694,10 @@ private fun WeekPager(
}
ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) {
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,
allDayHeight = allDayHeight,
scrollState = scrollState,
@@ -628,6 +707,7 @@ private fun WeekPager(
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -662,6 +742,7 @@ private fun TimelineMorphFrame(
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
val fromState by morph.fromState.collectAsStateWithLifecycle()
val toState by morph.toState.collectAsStateWithLifecycle()
@@ -717,6 +798,7 @@ private fun TimelineMorphFrame(
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -810,6 +892,7 @@ internal fun WeekSuccess(
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
WeekFrame(
weekStart = state.weekStart,
@@ -829,6 +912,7 @@ internal fun WeekSuccess(
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -851,6 +935,7 @@ private fun WeekPage(
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
val geometry = remember(state.weekStart, state.days.size) {
ColumnGeometry.of(DaySpan(state.weekStart, state.days.size))
@@ -870,6 +955,7 @@ private fun WeekPage(
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
@@ -893,6 +979,7 @@ private fun TimelinePageContent(
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
@@ -923,6 +1010,7 @@ private fun TimelinePageContent(
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -1071,6 +1159,7 @@ private fun AllDayStrip(
key(bar.event.eventId, bar.event.start) {
AllDayBar(
event = bar.event,
lastDay = bar.toRange.endInclusive,
dark = dark,
onClick = { onEventClick(bar.event) },
modifier = Modifier
@@ -1106,13 +1195,15 @@ private fun AllDayStrip(
@Composable
private fun AllDayBar(
event: EventInstance,
lastDay: LocalDate,
dark: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
// A bar is one piece, so it dims once the last day it covers here has passed.
val dimmed = dimCutoff != null && event.isPastOn(lastDay, dimCutoff)
val paint = eventPaint(event, dark)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
@@ -1149,6 +1240,7 @@ private fun Timeline(
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
val fullViewportHeight = remember(geometry) { intArrayOf(-1) }
val dark = isSystemInDarkTheme()
@@ -1246,6 +1338,7 @@ private fun Timeline(
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
scrollState = scrollState,
modifier = Modifier.fillMaxHeight(),
)
@@ -1268,6 +1361,7 @@ private fun DayColumnCard(
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
scrollState: ScrollState,
modifier: Modifier = Modifier,
) {
@@ -1316,6 +1410,10 @@ private fun DayColumnCard(
// blocks are consumed by their own handler first. Snaps to hour.
.pointerInput(date) {
detectTapGestures { offset ->
if (dragController.selection != null) {
dragController.deselect()
return@detectTapGestures
}
if (currentEdges.value.tap(offset.y)) return@detectTapGestures
currentOnCreateAt.value(
date,
@@ -1375,6 +1473,7 @@ private fun DayColumnCard(
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
onResize = onResize,
modifier = Modifier
.laneSlot(place)
.offset(y = place.y + topInset)
@@ -1419,6 +1518,7 @@ private fun EventBlock(
dragController: TimelineDragController,
onClick: () -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
modifier: Modifier = Modifier,
) {
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
@@ -1469,72 +1569,173 @@ private fun EventBlock(
if (showTime) metrics.timeLine else 0.dp
val timeMaxLines = if (showTime) blockTimeLines(shownTime, textWidth, spare) else 1
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
val dimmed = dimCutoff != null && block.event.isPastOn(date, dimCutoff)
val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event)
val draggable = eventDragAllowed(block.event)
val selectable = eventSelectAllowed()
val selected = dragController.isSelected(block)
val haptics = LocalHapticFeedback.current
// The drop takes this offset back off, so a tail clipped at midnight lands
// where the event's own start belongs (#253).
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
val topInsetPx = with(density) { topInset.toPx() }
// Long press (#141): move to drag, let go to select; selected blocks drag at once.
val dragModifier = rememberEventDragSource(
enabled = draggable,
enabled = selectable,
key = block.event.instanceId,
immediate = selected && draggable,
onPickUp = { pointer, blockRoot, _ ->
dragController.begin(
block = block,
clipOffsetMin = clipOffset,
titleLines = if (showTitle) titleMaxLines else 0,
pointerInRoot = pointer,
// Measured off the block's placement rather than off where the
// hour grid seats it: the copy is drawn from that placement and
// re-seated the same half-gap down (#267).
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
)
if (draggable) {
dragController.begin(
block = block,
clipOffsetMin = clipOffset,
titleLines = if (showTitle) titleMaxLines else 0,
pointerInRoot = pointer,
// Measured off the block's placement rather than off where the
// hour grid seats it: the copy is drawn from that placement and
// re-seated the same half-gap down (#267).
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
)
PickUp.Drag
} else {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
dragController.select(block.event)
PickUp.Hold
}
},
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel,
onRelease = { dragController.select(block.event, fromLift = draggable) },
)
val lifted = draggable && dragController.ghosts(block)
val ghost = ghostAlpha(lifted)
// Released in place, the block takes over the copy's lift without a dip.
val takesOverLift = selected && dragController.selection?.fromLift == true
val ghostFade = ghostAlpha(lifted)
val ghost = if (takesOverLift && !lifted) 1f else ghostFade
val raise = selectionLift(selected, snap = takesOverLift)
// Tells the action bar to fold when it would cover the bottom handle.
val revealExtraPx = with(density) { (ResizeHandleTarget / 2).toPx() }
val barClearancePx = with(density) { SelectionBarClearance.toPx() }
val tracksBar = selected && !cuts.bottom
// Not while its own write is pending: it still shows the old times.
LaunchedEffect(selected, block.event, lifted) {
if (selected && !lifted) dragController.adoptSelection(block)
}
// Kept composed while hidden: removing them would cancel their own resize.
DisposableEffect(selected, block.event) {
if (selected) dragController.noteSelectedBlock(block.event, true)
onDispose { if (selected) dragController.noteSelectedBlock(block.event, false) }
}
val showHandles = selected && draggable
val handlesShown = !lifted && !dragController.isDragging
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
modifier = (if (dimmed && !selected) modifier.alpha(EventDimAlpha) else modifier)
.zIndex(if (selected) 1f else 0f)
// The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.eventSurface(paint, shape, cuts)
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
},
.then(if (raise > 0f) Modifier.selectionLift(raise, shape) else Modifier)
.then(
if (tracksBar) {
Modifier.onGloballyPositioned {
dragController.noteSelectedBottom(it, revealExtraPx, barClearancePx)
}
} else {
Modifier
},
),
) {
Column {
if (showTitle) {
BlockTitle(
title = title,
maxLines = titleMaxLines,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
Box(
modifier = Modifier
.fillMaxSize()
.eventSurface(paint, shape, cuts)
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
},
) {
Column {
if (showTitle) {
BlockTitle(
title = title,
maxLines = titleMaxLines,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
)
}
if (showTime) {
BlockTimeLabel(
label = shownTime,
color = paint.secondaryInk,
maxLines = timeMaxLines,
)
}
}
}
if (showHandles) {
val accent = eventAccent(block.event.color, dark, LocalSoftenColors.current)
val handleLines = if (showTitle) titleMaxLines else 0
val begin = { edge: ResizeEdge ->
{ pointer: Offset ->
dragController.beginResize(block, edge, zone, handleLines, pointer)
}
}
val end = { dragController.finishResize(zone)?.let(onResize) }
val handleIn by animateFloatAsState(
targetValue = if (handlesShown) 1f else 0f,
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
label = "handles",
)
val handleAlpha = Modifier.graphicsLayer {
alpha = handleIn.coerceIn(0f, 1f)
scaleX = 0.5f + 0.5f * handleIn
scaleY = 0.5f + 0.5f * handleIn
}
if (!cuts.top) {
ResizeHandle(
color = accent,
enabled = handlesShown,
onStart = begin(ResizeEdge.Top),
onMove = dragController::move,
onEnd = { end() },
onCancel = dragController::cancel,
modifier = Modifier
.align(Alignment.TopStart)
.offset(x = HANDLE_INSET - ResizeHandleTarget / 2, y = -ResizeHandleTarget / 2)
.then(handleAlpha),
)
}
if (showTime) {
BlockTimeLabel(
label = shownTime,
color = paint.secondaryInk,
maxLines = timeMaxLines,
if (!cuts.bottom) {
ResizeHandle(
color = accent,
enabled = handlesShown,
onStart = begin(ResizeEdge.Bottom),
onMove = dragController::move,
onEnd = { end() },
onCancel = dragController::cancel,
modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = ResizeHandleTarget / 2 - HANDLE_INSET, y = ResizeHandleTarget / 2)
.then(handleAlpha),
)
}
}
}
}
private const val SELECTION_ORPHAN_GRACE_MILLIS = 500L
/** How far in from the block's side a resize handle's centre sits. */
private val HANDLE_INSET = 16.dp
private val SKELETON_HEADER_HEIGHT = 36.dp
private val SKELETON_HEADER_PADDING = 8.dp
@@ -7,6 +7,8 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
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.EventInstance
import de.jeanlucmakiola.calendula.domain.displayPriority
@@ -27,6 +29,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
@@ -40,7 +43,6 @@ import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import java.util.Locale
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
import kotlin.math.abs
@@ -73,6 +75,7 @@ fun positionOf(anchor: LocalDate, rule: (LocalDate) -> PageGrid): PagerPosition
abstract class PagedTimelineViewModel(
private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
private val io: CoroutineDispatcher,
gridRule: Flow<(LocalDate) -> PageGrid>,
initialRule: (LocalDate) -> PageGrid,
@@ -89,7 +92,7 @@ abstract class PagedTimelineViewModel(
)
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
// settings change re-frames the same days instead of jumping.
@@ -103,6 +106,17 @@ abstract class PagedTimelineViewModel(
val rule: StateFlow<(LocalDate) -> PageGrid> =
gridRule.stateIn(viewModelScope, SharingStarted.Eagerly, initialRule)
private var lastToday = todayDate
/** Today, kept current across midnight so a page left open doesn't mark yesterday (#373). */
private val today: StateFlow<LocalDate> = todaySource.today
.onEach { date ->
val old = lastToday
lastToday = date
if (old != date) followToday(old, date)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
private val position: StateFlow<PagerPosition> =
combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) }
.distinctUntilChanged()
@@ -141,8 +155,9 @@ abstract class PagedTimelineViewModel(
combine(
repository.calendars(),
repository.instances(spanRange(span.start, span.count, zone)),
) { calendars, instances ->
buildState(span, calendars, instances)
today,
) { calendars, instances, today ->
buildState(span, calendars, instances, today)
}
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
@@ -163,12 +178,20 @@ abstract class PagedTimelineViewModel(
}
fun goToToday() = jumpTo(todayDate)
/** The date rolled over; a page still showing [old] follows on to [new], one browsed elsewhere stays. */
private fun followToday(old: LocalDate, new: LocalDate) {
val anchor = _anchor.value
val grid = rule.value(anchor)
if (grid.pageFor(anchor) == grid.pageFor(old)) _anchor.value = new
}
fun goToDate(date: LocalDate) = jumpTo(date)
private fun buildState(
span: DaySpan,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
today: LocalDate,
): WeekUiState {
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
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 }
return WeekUiState.Success(
weekStart = span.start,
today = todayDate,
today = today,
days = days,
allDaySpans = layoutAllDay(allDay, days, zone),
timedByDay = days.associateWith { day -> layoutDay(timed, day, zone) },
@@ -189,10 +212,12 @@ abstract class PagedTimelineViewModel(
class WeekViewModel @Inject constructor(
repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
@IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel(
repository = repository,
settingsPrefs = settingsPrefs,
todaySource = todaySource,
io = io,
gridRule = settingsPrefs.weekStart.map { pref ->
val grid = WeekGrid(pref.resolveFirstDay(Locale.getDefault()))
@@ -211,10 +236,12 @@ class WeekViewModel @Inject constructor(
class MultiDayViewModel @Inject constructor(
repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
@IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel(
repository = repository,
settingsPrefs = settingsPrefs,
todaySource = todaySource,
io = io,
gridRule = settingsPrefs.multiDayCount.map { count ->
{ anchor: LocalDate -> DaysGrid.startingAt(anchor, count) }
@@ -227,10 +254,12 @@ class MultiDayViewModel @Inject constructor(
class DayViewModel @Inject constructor(
repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
@IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel(
repository = repository,
settingsPrefs = settingsPrefs,
todaySource = todaySource,
io = io,
gridRule = flowOf { _: LocalDate -> DayGrid },
initialRule = { DayGrid },
@@ -58,6 +58,7 @@ internal fun WeekViewPreview(
onOpenDay = {},
onCreateAt = { _, _ -> },
onDrop = {},
onResize = {},
)
}
}
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
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.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
import kotlinx.coroutines.flow.first
import kotlinx.datetime.DateTimeUnit
@@ -28,11 +29,12 @@ import kotlin.time.Clock
import kotlin.time.Instant
/**
* How far either side of today the month widget pre-loads. The displayed month
* is chosen reactively in the composition, so one wide read covers ~13 months of
* prev/next navigation without re-querying on every arrow tap.
* Months either side of the displayed one the month widget loads, so a few arrow
* taps page without a re-query. Kept small: every load expands recurrences across
* 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()
@@ -76,6 +78,8 @@ sealed interface AgendaWidgetData {
val savedShowToday: Boolean,
/** Saved widget size step (#103) — the fallback before Glance state is set. */
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. */
val now: Instant,
) : AgendaWidgetData
@@ -94,14 +98,15 @@ sealed interface MonthWidgetSource {
val today: LocalDate,
val weekStart: DayOfWeek,
val instances: List<EventInstance>,
/** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */
val months: IntRange,
) : MonthWidgetSource
}
/**
* Process-lived cache of the wide month window. Month navigation re-runs
* `provideGlance` (via `updateAll`), and re-querying ~13 months of instances on
* every arrow tap is what made paging feel sluggish — so we load once and reuse
* the same snapshot for every nearby month. Invalidated by
* Process-lived cache of the month window. Month navigation can re-run
* `provideGlance`, and re-querying on every arrow tap is what made paging feel
* sluggish — so we load once and reuse the same snapshot for every nearby month. Invalidated by
* [invalidateMonthWidgetCache] when calendar data changes (the freshness
* 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
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
* [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
val zone = systemZone()
val anchor = today(zone)
@@ -129,11 +137,11 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
val savedPastDisplay = prefs.pastEventDisplay.first()
val showToday = prefs.agendaShowToday.first()
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
// Load the widest selectable window once; the displayed range is sliced in
// the composition from Glance state, so changing the range is a plain
// recomposition and never depends on the widget session restarting.
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
val windowEnd = anchor.plus(AgendaRange.MAX_CUSTOM_DAYS - 1, DateTimeUnit.DAY)
// The displayed range is still sliced in the composition from Glance state;
// a wider one picked later reloads there (see loadedThrough).
val dayCount = (range ?: savedRange).dayCount(anchor, weekStart)
val window = agendaRange(anchor, dayCount - 1, zone)
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
val instances = ep.calendarRepository().instances(window).first()
val is24Hour = prefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
@@ -148,22 +156,39 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
savedPastDisplay = savedPastDisplay,
savedShowToday = showToday,
savedWidgetSize = prefs.widgetSize.first(),
loadedThrough = windowEnd,
now = Clock.System.now(),
)
}
/** One-shot wide read backing the month widget's grid for any nearby month. */
internal suspend fun Context.loadMonthWidgetSource(): MonthWidgetSource {
internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.ordinal
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
val zone = systemZone()
val anchor = today(zone)
val center = centerIndex ?: monthIndexOf(anchor)
// 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 weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
val from = anchor.minus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atStartOfDayIn(zone)
val to = anchor.plus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
val months = (center - MONTH_WIDGET_PAD_MONTHS)..(center + MONTH_WIDGET_PAD_MONTHS)
// 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()
return MonthWidgetSource.Ready(today = anchor, weekStart = weekStart, instances = instances)
.also { MonthWidgetCache.data = it }
return MonthWidgetSource.Ready(
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.res.Configuration
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.unit.dp
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.ColorFilter
@@ -28,6 +34,7 @@ import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
@@ -125,7 +132,8 @@ class AgendaWidget : GlanceAppWidget() {
override val sizeMode = SizeMode.Single
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
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
provideContent {
@@ -190,71 +198,95 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
is AgendaWidgetData.Ready -> {
// Range read reactively from per-instance Glance state (falls back
// to the saved pref for a freshly placed widget), then the wide
// pre-loaded window is sliced to it — pure recomposition.
// to the saved pref for a freshly placed widget), then the loaded
// window is sliced to it — pure recomposition.
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
val pastDisplay =
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 {
days
}
},
today = data.today,
enabled = showToday,
)
if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title, metrics)
val context = androidx.glance.LocalContext.current
var loaded by remember(data) { mutableStateOf<AgendaWidgetData.Ready>(data) }
// A range wider than what was loaded reloads in a live session,
// where provideGlance doesn't run again (#363).
LaunchedEffect(range) {
val end = loaded.today.plus(
range.dayCount(loaded.today, loaded.weekStart) - 1,
DateTimeUnit.DAY,
)
if (end > loaded.loadedThrough) {
(context.loadAgendaWidgetData(range) as? AgendaWidgetData.Ready)
?.let { loaded = it }
}
}
AgendaRows(data = loaded, range = range, dark = dark, metrics = metrics)
}
}
}
}
@Composable
private fun AgendaRows(
data: AgendaWidgetData.Ready,
range: AgendaRange,
dark: Boolean,
metrics: AgendaMetrics,
) {
val pastDisplay =
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 {
val rows = buildList {
visibleDays.forEach { day ->
add(AgendaRow.Header(day.date, data.today))
if (day.events.isEmpty()) {
add(AgendaRow.Placeholder(day.date))
} else {
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
}
}
}
// Bound the payload, then drop a day header the cut left
// stranded with nothing under it.
.take(MAX_AGENDA_ROWS)
.dropLastWhile { it is AgendaRow.Header }
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(rows.size) { index ->
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(
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,
)
}
}
}
days
}
},
today = data.today,
enabled = showToday,
)
if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title, metrics)
} else {
val rows = buildList {
visibleDays.forEach { day ->
add(AgendaRow.Header(day.date, data.today))
if (day.events.isEmpty()) {
add(AgendaRow.Placeholder(day.date))
} else {
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
}
}
}
// Bound the payload, then drop a day header the cut left
// stranded with nothing under it.
.take(MAX_AGENDA_ROWS)
.dropLastWhile { it is AgendaRow.Header }
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(rows.size) { index ->
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(
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.res.Configuration
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.sp
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
@@ -26,6 +32,7 @@ import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.background
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.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
import de.jeanlucmakiola.calendula.widget.monthIndexOf
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
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 GRID_HPADDING = 8.dp
private fun currentMonthIndex(zone: TimeZone): Int {
val t = today(zone)
return t.year * 12 + t.month.ordinal
}
private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone))
private fun yearMonthOf(index: Int): YearMonth =
YearMonth(index / 12, Month(index % 12 + 1))
@@ -123,7 +128,8 @@ class MonthWidget : GlanceAppWidget() {
override val sizeMode = SizeMode.Exact
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
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
// 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 index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
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.
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))
WeekdayHeader(weekStart = source.weekStart, colW = colW)
WeekdayHeader(weekStart = loaded.weekStart, colW = colW)
weeks.forEach { week ->
WeekRow(
week = week,
currentMonth = ym.month,
today = source.today,
today = loaded.today,
dark = dark,
soften = soften,
colW = colW,
+6
View File
@@ -97,6 +97,12 @@
<string name="event_move_occurrence_only">Moving the whole series would change which days it falls on, so only this event can move.</string>
<!-- %1$s is the new date and time, e.g. "Fri, 7 Aug, 09:00". -->
<string name="event_move_done">Moved to %1$s</string>
<string name="event_resize_done">Changed to %1$s</string>
<string name="event_resize_recurring_title">Change recurring event</string>
<string name="event_selection_actions">Event actions</string>
<string name="event_selection_hide">Hide event actions</string>
<string name="event_selection_close">Deselect event</string>
<string name="event_action_load_failed">Couldn\'t open the event</string>
<string name="event_move_undo">Undo</string>
<string name="event_move_undone">Move undone</string>
<string name="event_move_failed">Couldn\'t move the event</string>
@@ -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
fun `instances forwards epoch-millis bounds to data source`(@TempDir tempDir: Path) = runTest {
var observedBegin: Long? = null
@@ -152,4 +152,49 @@ class EventShiftTest {
val original = form()
assertThat(original.shiftedByDays(0, berlin)).isEqualTo(original)
}
@Test
fun `resizing sets each edge on its own`() {
val original = form()
val end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)).toInstant(berlin)
val resized = original.resizedTo(original.start.toInstant(berlin), end, berlin)
assertThat(resized.start).isEqualTo(original.start)
assertThat(resized.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)))
}
@Test
fun `a resize past midnight carries the end onto the next day`() {
val original = form()
val end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(1, 0)).toInstant(berlin)
val resized = original.resizedTo(original.start.toInstant(berlin), end, berlin)
assertThat(resized.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(1, 0)))
}
@Test
fun `a pinned event is resized in its own zone`() {
val original = form(
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
timezone = "America/New_York",
)
// "15:30 Berlin" is 09:30 in New York.
val start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(15, 30)).toInstant(berlin)
val resized = original.resizedTo(start, original.end.toInstant(newYork), berlin)
assertThat(resized.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
assertThat(resized.end).isEqualTo(original.end)
}
@Test
fun `an all-day event ignores a resize`() {
val allDay = form(isAllDay = true)
val start = allDay.start.toInstant(berlin)
assertThat(allDay.resizedTo(start, start, berlin)).isEqualTo(allDay)
}
}
@@ -9,6 +9,7 @@ class AnchorTodayTest {
private val today = LocalDate(2026, 6, 17)
private val tomorrow = LocalDate(2026, 6, 18)
private val yesterday = LocalDate(2026, 6, 16)
@Test
fun `disabled leaves the days untouched even when today is absent`() {
@@ -36,4 +37,18 @@ class AnchorTodayTest {
val days = listOf(AgendaDay(today, emptyList()), AgendaDay(tomorrow, emptyList()))
assertThat(anchorTodayIfMissing(days, today, enabled = true)).isEqualTo(days)
}
@Test
fun `enabled slots today in after loaded past days`() {
val days = listOf(AgendaDay(yesterday, emptyList()), AgendaDay(tomorrow, emptyList()))
val result = anchorTodayIfMissing(days, today, enabled = true)
assertThat(result.map { it.date }).containsExactly(yesterday, today, tomorrow).inOrder()
}
@Test
fun `enabled appends today after only past days`() {
val days = listOf(AgendaDay(yesterday, emptyList()))
val result = anchorTodayIfMissing(days, today, enabled = true)
assertThat(result.map { it.date }).containsExactly(yesterday, today).inOrder()
}
}
@@ -0,0 +1,49 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class IsPastOnTest {
private val zone = TimeZone.of("Europe/Berlin")
private val today = LocalDate(2026, 10, 8)
private val now = Instant.parse("2026-10-08T12:00:00Z")
// All-day Oct 6–9, still running today.
private val summit = EventInstance(
instanceId = 1,
eventId = 1,
calendarId = 1,
title = "Summit",
start = Instant.parse("2026-10-06T00:00:00Z"),
end = Instant.parse("2026-10-10T00:00:00Z"),
isAllDay = true,
color = 0,
location = null,
)
@Test
fun `an ongoing multi-day event is past on the days already behind`() {
assertThat(summit.isPastOn(LocalDate(2026, 10, 7), now, zone)).isTrue()
}
@Test
fun `an ongoing multi-day event is not past today or later`() {
assertThat(summit.isPastOn(today, now, zone)).isFalse()
assertThat(summit.isPastOn(LocalDate(2026, 10, 9), now, zone)).isFalse()
}
@Test
fun `an event that ended earlier today is past`() {
val standup = summit.copy(
isAllDay = false,
start = Instant.parse("2026-10-08T07:00:00Z"),
end = Instant.parse("2026-10-08T07:15:00Z"),
)
assertThat(standup.isPastOn(today, now, zone)).isTrue()
}
}
@@ -115,6 +115,17 @@ class RescheduleViewModelTest {
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis + 3_600_000L)),
)
/** The end dragged an hour later, the start left where it is. */
private fun endOneHourLater() = MoveRequest(
eventId = 42L,
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Span(
Instant.fromEpochMilliseconds(beginMillis),
Instant.fromEpochMilliseconds(endMillis + 3_600_000L),
),
)
@Test
fun `a one-off drop writes straight through with no scope prompt`(
@TempDir tempDir: Path,
@@ -595,4 +606,82 @@ class RescheduleViewModelTest {
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
assertThat(vm.abandoned.value).isEqualTo(0)
}
@Test
fun `a resize writes the new length and reports both ends`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(endOneHourLater())
advanceUntilIdle()
val (_, original, updated) = fake.updatedEvents.single()
assertThat(updated.start).isEqualTo(original.start)
assertThat(updated.end).isNotEqualTo(original.end)
val moved = vm.outcome.value as MoveOutcome.Moved
assertThat(moved.startMillis).isEqualTo(beginMillis)
assertThat(moved.endMillis).isEqualTo(endMillis + 3_600_000L)
assertThat(moved.undo).isNotNull()
}
@Test
fun `a moved event reports no end, so its chip keeps the move wording`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat((vm.outcome.value as MoveOutcome.Moved).endMillis).isNull()
}
@Test
fun `resizing a recurring event asks for the scope, as a resize`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
}
val vm = viewModel(tempDir, fake)
vm.move(endOneHourLater())
advanceUntilIdle()
assertThat(vm.scopePrompt.value)
.isEqualTo(MoveScopePrompt(occurrenceOnly = false, resize = true))
vm.moveWithScope(RecurringWriteScope.AllEvents)
advanceUntilIdle()
// The start stays put, so the rule needs no realigning.
assertThat(fake.updatedEvents.single().third.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
}
@Test
fun `pulling a recurring event's start back over midnight resizes only the occurrence`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
}
val vm = viewModel(tempDir, fake)
val sundayEvening = LocalDateTime(LocalDate(2026, 6, 7), LocalTime(22, 0))
.toInstant(TimeZone.currentSystemDefault())
vm.move(
MoveRequest(
eventId = 42L,
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Span(sundayEvening, Instant.fromEpochMilliseconds(endMillis)),
),
)
advanceUntilIdle()
// Not a whole-day shift, so the series anchor can't follow.
assertThat(vm.scopePrompt.value)
.isEqualTo(MoveScopePrompt(occurrenceOnly = true, resize = true))
}
}
@@ -0,0 +1,105 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/** Where a dragged resize handle leaves an event's ends (#141). */
class ResizedSpanTest {
// 09:00–10:00, in minutes from its day's midnight.
private val start = 9 * 60
private val end = 10 * 60
@Test
fun `the bottom edge snaps and leaves the start alone`() {
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 11 * 60 + 8f))
.isEqualTo(start to 11 * 60 + 15)
}
@Test
fun `the top edge snaps and leaves the end alone`() {
assertThat(resizedSpan(ResizeEdge.Top, start, end, 8 * 60 - 7f))
.isEqualTo(8 * 60 to end)
}
@Test
fun `an edge stops one snap step short of the other end`() {
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 7 * 60f))
.isEqualTo(start to start + DRAG_SNAP_MINUTES)
assertThat(resizedSpan(ResizeEdge.Top, start, end, 12 * 60f))
.isEqualTo(end - DRAG_SNAP_MINUTES to end)
}
@Test
fun `the bottom edge runs on past midnight`() {
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 24 * 60 + 90f))
.isEqualTo(start to 24 * 60 + 90)
}
@Test
fun `the top edge runs back into the day before`() {
assertThat(resizedSpan(ResizeEdge.Top, start, end, -60f))
.isEqualTo(-60 to end)
}
@Test
fun `wall clock minutes past either midnight land on the neighbouring day`() {
val zone = TimeZone.of("Europe/Berlin")
val day = LocalDate(2026, 10, 9)
assertThat(wallClockInstant(day, 24 * 60 + 30, zone))
.isEqualTo(LocalDateTime(LocalDate(2026, 10, 10), LocalTime(0, 30)).toInstant(zone))
assertThat(wallClockInstant(day, -30, zone))
.isEqualTo(LocalDateTime(LocalDate(2026, 10, 8), LocalTime(23, 30)).toInstant(zone))
}
@Test
fun `a finger drifting just over a column border keeps the edge on its day`() {
assertThat(stickyColumn(position = 3.1f, held = 2, lastIndex = 6)).isEqualTo(2)
assertThat(stickyColumn(position = 1.9f, held = 2, lastIndex = 6)).isEqualTo(2)
}
@Test
fun `a finger well into the next column takes the edge there`() {
assertThat(stickyColumn(position = 3.4f, held = 2, lastIndex = 6)).isEqualTo(3)
assertThat(stickyColumn(position = 1.6f, held = 2, lastIndex = 6)).isEqualTo(1)
}
@Test
fun `the first column is the one under the finger`() {
assertThat(stickyColumn(position = 4.9f, held = null, lastIndex = 6)).isEqualTo(4)
assertThat(stickyColumn(position = 9f, held = null, lastIndex = 6)).isEqualTo(6)
}
@Test
fun `a finger resting between two slots keeps the one it is on`() {
// 09:08 rounds to 09:15, but from 09:00 it is not far enough to leave.
assertThat(stickySnap(9 * 60 + 8f, held = 9 * 60)).isEqualTo(9 * 60)
assertThat(stickySnap(9 * 60 + 12f, held = 9 * 60)).isEqualTo(9 * 60 + 15)
assertThat(stickySnap(9 * 60 + 8f, held = null)).isEqualTo(9 * 60 + 15)
}
@Test
fun `an edge dragged into a day past the other end changes nothing`() {
// The start into the day after a same-day event ends.
assertThat(resizedSpan(ResizeEdge.Top, start, end, 24 * 60 + 9 * 60f, edgeDay = 1))
.isEqualTo(start to end)
// The end into the day before it starts.
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, -60f, edgeDay = -1))
.isEqualTo(start to end)
}
@Test
fun `an edge in the event's own days still clamps against the other end`() {
assertThat(resizedSpan(ResizeEdge.Top, start, end, 12 * 60f, edgeDay = 0))
.isEqualTo(end - DRAG_SNAP_MINUTES to end)
// An overnight event's start may move anywhere up to its end's day.
val overnightEnd = 24 * 60 + 2 * 60
assertThat(resizedSpan(ResizeEdge.Top, start, overnightEnd, 24 * 60 + 60f, edgeDay = 1))
.isEqualTo(24 * 60 + 60 to overnightEnd)
}
}
+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.
2. **Observer-driven UI.** A `ContentObserver` on the provider triggers
re-queries; every screen recomposes from fresh provider state. After a
write, nothing is patched by hand — the provider notifies, the views
refresh. This also covers external changes (sync) for free.
write, nothing is patched by hand — the views refresh from the provider.
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
`ContentResolver` is shaped so it runs as a plain JUnit 5 test: pure
domain logic, cursor-free mappers, a `FakeCalendarDataSource` for
+74 -6
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
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
A release is defined by the `versionName`/`versionCode` committed in
`app/build.gradle.kts`:
- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `2.1.0`)
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`2.1.0` → `20100`)
- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `2.22.4`), or
`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
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
fully-shipped version (and a failure before publish leaves no tag, so re-running
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
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
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
`## [X.Y.Z] — <date>` heading (Keep a Changelog format). The text between
that heading and the next `## [` becomes both the Gitea release notes and
the F-Droid per-version changelog.
3. Bump the committed `versionName` (and `versionCode`) in
`app/build.gradle.kts` to the new version. **This bump is what triggers the
3. Bump the committed `versionName` (and `versionCode`, which
`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
"What's New" by hand to
`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 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
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
(both best-effort). Ordinary merges with no version bump fall through `detect`
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
Google Play. Runs last and separately so a Play rejection can't endanger a
release that already shipped; skips cleanly until Play is configured.
@@ -0,0 +1,3 @@
إصلاحات
• الأحداث المعدّلة أو المنقولة أو المحذوفة تظهر الآن فورًا على كل الهواتف. على بعض الهواتف، ومنها سامسونج، كان الحدث المعدّل يبقى كما هو حتى إعادة تشغيل التطبيق.
• أدوات الشاشة الرئيسية تحمّل الآن الأحداث التي تعرضها فقط. هذا يُفترض أن يساعد الأدوات التي كانت تبقى على «جارٍ التحميل» في الهواتف الأبطأ.
@@ -0,0 +1,2 @@
إصلاحات
• عند فتح التطبيق في صباح اليوم التالي يظهر اليوم الصحيح. كان عرض الشهر يعلّم الأمس على أنه اليوم ويُبقي اليوم الذي اخترته في المساء السابق محددًا. الآن تنتقل كل العروض إلى اليوم الجديد.
@@ -0,0 +1,3 @@
Выпраўлена
• Змененыя, перанесеныя і выдаленыя падзеі цяпер адразу адлюстроўваюцца на любым тэлефоне. На некаторых, у тым ліку Samsung, змененая падзея заставалася ранейшай да перазапуску праграмы.
• Віджэты на галоўным экране загружаюць толькі тыя падзеі, якія паказваюць. Гэта павінна дапамагчы віджэтам, якія на павольных тэлефонах затрымліваліся на «загрузцы».
@@ -0,0 +1,2 @@
Выпраўлена
• Калі адкрыць праграму наступнай раніцай, яна паказвае правільны дзень. Раней месяц адзначаў учорашні дзень як сённяшні і пакідаў выбраным дзень, які вы абралі ўвечары. Цяпер усе віды пераходзяць на новы дзень.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -0,0 +1,3 @@
修正
• 予定の編集・移動・削除が、どの端末でもすぐに反映されるようになりました。Samsung など一部の端末では、アプリを再起動するまで変更前の予定が表示されたままでした。
• ホーム画面のウィジェットは、表示する予定だけを読み込むようになりました。動作の遅い端末で「読み込み中」のまま止まるウィジェットが改善されるはずです。
@@ -0,0 +1,2 @@
修正
• 翌朝アプリを開くと、正しい日が表示されるようになりました。これまで月表示では前日が今日として表示され、前の晩に選んだ日も選択されたままでした。今はすべての表示が新しい日に切り替わります。
@@ -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,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ń.
@@ -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.
@@ -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.
@@ -0,0 +1,2 @@
Corrigido
• Ao abrir a app na manhã seguinte, aparece o dia certo. A vista do mês marcava ontem como hoje e deixava selecionado o dia escolhido na noite anterior. Agora todas as vistas passam para o novo dia.
@@ -0,0 +1,3 @@
Исправлено
• Изменённые, перенесённые и удалённые события теперь сразу отображаются на любом телефоне. На некоторых, в том числе Samsung, изменённое событие оставалось прежним до перезапуска приложения.
• Виджеты на главном экране загружают только те события, которые показывают. Это должно помочь виджетам, которые на медленных телефонах зависали на «загрузке».
@@ -0,0 +1,2 @@
Исправлено
• Если открыть приложение на следующее утро, оно показывает правильный день. Раньше месяц отмечал вчерашний день как сегодняшний и не снимал выделение с дня, выбранного накануне вечером. Теперь все виды переходят на новый день.
@@ -0,0 +1,3 @@
Opravené
• Upravené, presunuté a odstránené udalosti sa teraz na každom telefóne prejavia hneď. Na niektorých, okrem iného Samsungu, zostala zmenená udalosť bez zmeny až do reštartu aplikácie.
• Widgety na domovskej obrazovke načítavajú len udalosti, ktoré zobrazujú. To by malo pomôcť widgetom, ktoré na pomalších telefónoch zostávali na „načítava sa“.
@@ -0,0 +1,2 @@
Opravené
• Keď aplikáciu otvoríte na druhý deň ráno, ukáže správny deň. Mesiac predtým označoval včerajšok ako dnešok a nechával vybraný deň, ktorý ste zvolili večer predtým. Všetky zobrazenia teraz prejdú na nový deň.
@@ -0,0 +1,3 @@
修复
• 编辑、移动或删除的日程现在在所有手机上都会立即显示。在部分手机(包括三星)上,修改后的日程要等重启应用后才会更新。
• 主屏幕小部件现在只加载其显示的日程。这应能改善在较慢手机上一直停在“加载中”的小部件。
@@ -0,0 +1,2 @@
修复
• 第二天早上打开应用时,会显示正确的日期。以前月视图会把昨天标为今天,并保留前一天晚上选中的日期。现在所有视图都会切换到新的一天。
+10 -10
View File
@@ -1,13 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
toolchainUrl.FREE_BSD.AARCH64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-linux-aarch64-b1163.116.tar.gz
toolchainUrl.FREE_BSD.X86_64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-linux-x64-b1163.116.tar.gz
toolchainUrl.LINUX.AARCH64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-linux-aarch64-b1163.116.tar.gz
toolchainUrl.LINUX.X86_64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-linux-x64-b1163.116.tar.gz
toolchainUrl.MAC_OS.AARCH64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-osx-aarch64-b1163.116.tar.gz
toolchainUrl.MAC_OS.X86_64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-osx-x64-b1163.116.tar.gz
toolchainUrl.UNIX.AARCH64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-linux-aarch64-b1163.116.tar.gz
toolchainUrl.UNIX.X86_64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-linux-x64-b1163.116.tar.gz
toolchainUrl.WINDOWS.AARCH64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-windows-aarch64-b1163.116.tar.gz
toolchainUrl.WINDOWS.X86_64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-windows-x64-b1163.116.tar.gz
toolchainVendor=JETBRAINS
toolchainVersion=21
+6 -6
View File
@@ -1,17 +1,17 @@
[versions]
agp = "9.2.1"
kotlin = "2.3.21"
agp = "9.4.1"
kotlin = "2.4.20"
ksp = "2.3.11"
hilt = "2.60.1"
coreKtx = "1.19.0"
appcompat = "1.7.1"
lifecycleRuntime = "2.11.0"
activityCompose = "1.13.0"
composeBom = "2026.06.01"
composeBom = "2026.09.00"
# Material 3 Expressive APIs currently live only in the 1.5 alpha line.
# Pin explicitly to override the BOM (which ships stable 1.4.0).
# Re-evaluate when 1.5.0 stable lands.
material3 = "1.5.0-alpha26"
material3 = "1.5.0-alpha29"
datastore = "1.2.1"
junit = "6.1.3"
junitPlatform = "6.1.3"
@@ -22,10 +22,10 @@ kotlinxDatetime = "0.7.0"
kotlinxCoroutines = "1.11.0"
turbine = "1.2.1"
hiltNavigationCompose = "1.4.0"
lifecycleCompose = "2.10.0"
lifecycleCompose = "2.11.0"
androidxTestRules = "1.7.0"
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
glance = "1.1.1"
glance = "1.2.0"
work = "2.11.2"
documentfile = "1.1.0"
Binary file not shown.
+4 -2
View File
@@ -1,8 +1,10 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f
distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+4 -7
View File
@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
@@ -29,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -114,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -172,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
Vendored
+52 -34
View File
@@ -19,12 +19,39 @@
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
@rem Catch executions from older scripts and ensure they exit cleanly.
@rem This can be removed once we can be reasonably confident that few people
@rem will be migrating directly to this new wrapper.
goto afterSafetyNet
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
goto exitWithErrorLevel
:afterSafetyNet
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@@ -45,13 +72,14 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
goto fail
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@@ -59,36 +87,26 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
goto fail
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+10 -3
View File
@@ -52,9 +52,16 @@
prConcurrentLimit: 5,
prHourlyLimit: 0,
// Cadence is owned by the Gitea Actions cron (.gitea/workflows/renovate.yml,
// Mondays) — no internal `schedule` here, so the two don't double-gate and
// silently skip a run.
// New branches/PRs only in the Monday window (UTC), which brackets the weekly
// cron in .gitea/workflows/renovate.yml with slack for a late start — keep
// the two in step, or the sweep lands outside the window and finds nothing.
// The same workflow also runs on every push to main; those runs fall outside
// the window and, via the default updateNotScheduled: true, only maintain
// existing branches — chiefly rebasing PRs a merge just conflicted.
schedule: ["* 4-6 * * 1"],
// Rebase only on conflict, not every time main moves (automerge is off, so
// this matches "auto"; spelled out because the on-merge trigger relies on it).
rebaseWhen: "conflicted",
// Gitea Actions workflows live under .gitea/workflows, not .github — extend
// the github-actions manager (same syntax) to watch them too.
+6 -5
View File
@@ -28,10 +28,11 @@ LIMIT=500
STRICT=0
[ "${1:-}" = "--strict" ] && STRICT=1
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
[ -n "$VERSION" ] || { echo "No versionName in app/build.gradle.kts" >&2; exit 1; }
MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.}
VERSION_CODE=$(( ${MAJOR:-0} * 10000 + ${MINOR:-0} * 100 + ${PATCH:-0} ))
VERSION=$(bash scripts/version_info.sh version)
VERSION_CODE=$(bash scripts/version_info.sh version_code)
# A beta ships no What's New, so only the older files are checked.
BETA=0
[ "$(bash scripts/version_info.sh channel)" = beta ] && BETA=1
fail=0
warned=0
@@ -65,7 +66,7 @@ for f in fastlane/metadata/android/*/changelogs/*.txt; do
fi
done
if [ "$current" -eq 0 ]; then
if [ "$current" -eq 0 ] && [ "$BETA" -eq 0 ]; then
echo "ERROR: no changelog for version $VERSION (code $VERSION_CODE)." >&2
echo " Write fastlane/metadata/android/en-US/changelogs/$VERSION_CODE.txt" >&2
echo " before releasing — see docs/RELEASING.md step 3." >&2
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Publishes TAG on Codeberg with the signed APK and its SHA-256 attached.
# Upserts, so re-runs are safe. A missing TOKEN skips a stable release but fails
# a pre-release, where Codeberg is the only channel.
#
# Env: TOKEN, API (.../api/v1/repos/<owner>/<repo>), TAG, SHA, PRERELEASE,
# NOTES_FILE, APK
set -euo pipefail
if [ -z "${TOKEN:-}" ]; then
if [ "${PRERELEASE:-}" = true ]; then
echo "CODEBERG_RELEASE_TOKEN not set — a pre-release has nowhere else to go." >&2
exit 1
fi
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
exit 0
fi
: "${API:?}" "${TAG:?}" "${SHA:?}" "${PRERELEASE:?}" "${NOTES_FILE:?}" "${APK:?}"
if [ ! -f "$APK" ]; then echo "No release APK at $APK." >&2; exit 1; fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
ASSET_APK="calendula_${TAG}.apk"
ASSET_SUM="${ASSET_APK}.sha256"
cp "$APK" "$WORK/$ASSET_APK"
( cd "$WORK" && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
# Push the tag first and create the release without target_commitish: a POST
# naming a commit Codeberg hasn't received yet 500s.
HOST=${API#https://}; HOST=${HOST%%/*}
REPO=${API#*/repos/}
git tag -f "$TAG" "$SHA"
git push -f "https://${REPO%%/*}:${TOKEN}@${HOST}/${REPO}.git" "refs/tags/$TAG"
python3 - "$TAG" "$PRERELEASE" "$NOTES_FILE" <<'PY' > "$WORK/payload.json"
import json, sys
tag, pre, notes = sys.argv[1:4]
print(json.dumps({
"tag_name": tag,
"name": tag,
"body": open(notes).read(),
"draft": False,
"prerelease": pre == "true",
}))
PY
# Codeberg 500s on a freshly pushed tag for a few seconds, hence the retries.
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
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @"$WORK/payload.json" "$API/releases/$EXIST" || echo 000)
echo "release PATCH HTTP $CODE"
if [ "$CODE" = 200 ]; then ID="$EXIST"; break; fi
else
CODE=$(curl -s -o "$WORK/response.json" -w '%{http_code}' -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @"$WORK/payload.json" "$API/releases" || echo 000)
echo "release POST attempt $attempt HTTP $CODE"
ID=$(jq -r '.id // empty' "$WORK/response.json" 2>/dev/null || true)
[ -n "$ID" ] && break
fi
sleep $((attempt * 10))
done
if [ -z "$ID" ]; then echo "Could not create or update the Codeberg release." >&2; exit 1; fi
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' 2>/dev/null || true)
for O in $OLD; do
curl -s -o /dev/null -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$O" || true
done
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$WORK/$A" "$API/releases/$ID/assets?name=$A" || echo 000)
echo "asset $A HTTP $CODE"
if [ "$CODE" != 201 ]; then echo "Uploading $A failed." >&2; exit 1; fi
done
echo "Published $TAG to Codeberg."

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